feat: implement Whisper transcription engine and project documentation

This commit is contained in:
Ümit Tunç
2026-04-23 08:56:57 +03:00
parent 0c46962cac
commit c22075e8aa
16 changed files with 891 additions and 120 deletions
+181 -50
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
const BIN_PATH = app.isPackaged
? path.join(process.resourcesPath, 'bin')
: path.join(app.getAppPath(), 'bin')
export async function detectHardware() {
// Simple check for NVIDIA GPU via nvidia-smi
return new Promise((resolve) => {
const smi = spawn('nvidia-smi')
smi.on('error', () => resolve({ gpu: false, name: 'CPU' }))
@@ -22,63 +22,194 @@ export async function detectHardware() {
})
}
// ── SRT Helpers ──────────────────────────────────────────────────────────
function srtTimeToMs(timeStr) {
const match = timeStr.match(/(\d+):(\d+):(\d+)[,.](\d+)/)
if (!match) return 0
const [, h, m, s, ms] = match
return parseInt(h) * 3600000 + parseInt(m) * 60000 + parseInt(s) * 1000 + parseInt(ms)
}
function msToSrtTime(ms) {
const h = Math.floor(ms / 3600000)
ms %= 3600000
const m = Math.floor(ms / 60000)
ms %= 60000
const s = Math.floor(ms / 1000)
const rem = ms % 1000
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')},${String(rem).padStart(3, '0')}`
}
// ── SRT Post-Processor ──────────────────────────────────────────────
// Const-me Whisper sometimes produces broken timestamps when splitting
// long segments. This function fixes them for Premiere Pro compatibility.
export function fixSrt(srtContent) {
// CRITIK DÜZELTME: Tüm Windows (\r\n) ve eski Mac (\r) satır sonlarını Linux (\n) standardına çevir
const normalizedContent = srtContent.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const blocks = normalizedContent.trim().split(/\n\n+/)
const entries = []
for (const block of blocks) {
const lines = block.trim().split('\n')
if (lines.length < 2) continue
const timeLine = lines.find((l) => l.includes('-->'))
if (!timeLine) continue
const textLines = lines.filter((l) => !l.includes('-->') && !/^\d+$/.test(l.trim()))
const text = textLines.join(' ').trim()
if (!text) continue
const [startStr, endStr] = timeLine.split('-->').map((s) => s.trim())
const startMs = srtTimeToMs(startStr)
let endMs = srtTimeToMs(endStr)
entries.push({ startMs, endMs, text })
}
// Pass 1: Fix broken timestamps (end < start) by merging with next entry
const merged = []
let i = 0
while (i < entries.length) {
const entry = { ...entries[i] }
if (entry.endMs <= entry.startMs && i + 1 < entries.length) {
const next = entries[i + 1]
entry.text = (entry.text + ' ' + next.text).trim()
entry.endMs = next.endMs
i += 2
} else {
i++
}
if (entry.endMs <= entry.startMs) {
entry.endMs = entry.startMs + 3000
}
merged.push(entry)
}
// Pass 2: Ensure timestamps are sequential
for (let j = 1; j < merged.length; j++) {
if (merged[j].startMs < merged[j - 1].endMs) {
merged[j].startMs = merged[j - 1].endMs
}
if (merged[j].endMs <= merged[j].startMs) {
merged[j].endMs = merged[j].startMs + 3000
}
}
// Build clean SRT
let srt = ''
merged.forEach((entry, idx) => {
srt += `${idx + 1}\n`
srt += `${msToSrtTime(entry.startMs)} --> ${msToSrtTime(entry.endMs)}\n`
srt += `${entry.text}\n\n`
})
return srt.trim() + '\n'
}
// ── Transcription Engine ────────────────────────────────────────────────
export function transcribe(filePath, options, onProgress, onData) {
return new Promise((resolve, reject) => {
const { model = 'base', language = 'tr', outputFormat = 'srt' } = options
// 1. FFmpeg Pre-processing (Convert to 16kHz mono WAV)
const tempWav = path.join(app.getPath('temp'), `voicext_${Date.now()}.wav`)
const ffmpegPath = path.join(BIN_PATH, 'ffmpeg.exe')
const ffmpeg = spawn(ffmpegPath, [
'-i', filePath,
'-ar', '16000',
'-ac', '1',
'-c:a', 'pcm_s16le',
tempWav,
'-y'
])
const { model = 'small', language = 'tr', format = 'srt' } = options
const whisperPath = path.join(BIN_PATH, 'whisper.exe')
const modelPath = path.join(BIN_PATH, 'models', `ggml-${model}.bin`)
ffmpeg.on('close', (code) => {
if (code !== 0) {
return reject(new Error('FFmpeg conversion failed'))
if (!fs.existsSync(whisperPath)) {
return reject(new Error(`Whisper executable not found at: ${whisperPath}`))
}
if (!fs.existsSync(modelPath)) {
return reject(new Error(`Model not found at: ${modelPath}. Run: node scripts/setup-binaries.js`))
}
const whisperArgs = ['-m', modelPath, '-f', filePath]
if (format === 'srt') whisperArgs.push('-osrt')
else if (format === 'vtt') whisperArgs.push('-ovtt')
else if (format === 'txt') whisperArgs.push('-otxt')
if (language && language !== 'auto') {
whisperArgs.push('-l', language)
}
console.log('[Voicext] Whisper args:', whisperArgs.join(' '))
const whisper = spawn(whisperPath, whisperArgs)
let stderrOutput = ''
// Windows console encoding usually requires special handling for Turkish characters
// Using iconv-lite to decode the buffer from CP857 (Turkish DOS) or CP1254 (Turkish Windows)
// Const-me Whisper CLI usually outputs in the system codepage.
whisper.stdout.on('data', (data) => {
// Decode buffer using Turkish Windows codepage (win1254) which covers Turkish characters
const text = iconv.decode(data, 'cp857')
console.log('[Whisper stdout]', text)
onData(text)
})
whisper.stderr.on('data', (data) => {
const text = iconv.decode(data, 'cp857')
stderrOutput += text
console.log('[Whisper stderr]', text)
// Progress parsing
if (text.includes('Loaded model')) {
onProgress(20)
} else if (text.includes('Loaded audio') || text.includes('source reader')) {
onProgress(40)
} else if (text.includes('RunComplete') || text.includes('CPU Tasks')) {
onProgress(80)
} else if (text.includes('Memory Usage')) {
onProgress(95)
}
})
// 2. Whisper.cpp Inference
const whisperPath = path.join(BIN_PATH, 'whisper.exe')
const modelPath = path.join(BIN_PATH, 'models', `ggml-${model}.bin`)
const whisper = spawn(whisperPath, [
'-m', modelPath,
'-f', tempWav,
`-o${outputFormat}`,
'-l', language,
'--max-len', '42'
])
whisper.on('error', (err) => {
console.error('[Voicext] Failed to start Whisper process:', err)
reject(new Error(`Failed to start Whisper: ${err.message}`))
})
whisper.stdout.on('data', (data) => {
onData(data.toString())
})
whisper.on('close', (code) => {
console.log(`[Voicext] Whisper exited with code: ${code}`)
const expectedOutput = filePath.replace(/\.[^/.]+$/, `.${format}`)
whisper.stderr.on('data', (data) => {
// Whisper.cpp outputs progress to stderr
const output = data.toString()
const progressMatch = output.match(/progress\s*=\s*(\d+)%/)
if (progressMatch) {
onProgress(parseInt(progressMatch[1]))
if (code === 0) {
if (format === 'srt' && fs.existsSync(expectedOutput)) {
try {
const raw = fs.readFileSync(expectedOutput, 'utf-8')
const fixed = fixSrt(raw)
fs.writeFileSync(expectedOutput, fixed, 'utf-8')
console.log('[Voicext] SRT post-processed and fixed')
} catch (e) {
console.warn('[Voicext] SRT post-processing warning:', e.message)
}
}
})
whisper.on('close', (code) => {
// Cleanup temp file
if (fs.existsSync(tempWav)) fs.unlinkSync(tempWav)
if (code === 0) {
resolve({ success: true, outputPath: filePath.replace(/\.[^/.]+$/, `.${outputFormat}`) })
} else {
reject(new Error('Whisper transcription failed'))
// Handle cases where whisper outputs to a different path
if (!fs.existsSync(expectedOutput)) {
const baseName = path.basename(filePath).replace(/\.[^/.]+$/, `.${format}`)
const altOutput = path.join(process.cwd(), baseName)
if (fs.existsSync(altOutput)) {
if (format === 'srt') {
const raw = fs.readFileSync(altOutput, 'utf-8')
const fixed = fixSrt(raw)
fs.writeFileSync(expectedOutput, fixed, 'utf-8')
} else {
fs.copyFileSync(altOutput, expectedOutput)
}
fs.unlinkSync(altOutput)
}
}
})
onProgress(100)
resolve({ success: true, outputPath: expectedOutput })
} else {
reject(new Error(`Whisper failed (code ${code}). Details:\n${stderrOutput}`))
}
})
})
}
}
+15 -11
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,10 +43,14 @@ function createWindow() {
filePath,
options,
(progress) => {
mainWindow.webContents.send('transcription-progress', progress)
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('transcription-progress', progress)
}
},
(data) => {
mainWindow.webContents.send('transcription-data', data)
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('transcription-data', data)
}
}
)
return result
@@ -60,6 +64,14 @@ function createWindow() {
ipcMain.handle('detect-hardware', async () => {
return await detectHardware()
})
// Handle window controls
ipcMain.on('window-controls', (event, action) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (action === 'minimize') win.minimize()
if (action === 'maximize') win.isMaximized() ? win.unmaximize() : win.maximize()
if (action === 'close') win.close()
})
}
app.whenReady().then(() => {
@@ -69,14 +81,6 @@ app.whenReady().then(() => {
optimizer.watchWindowShortcuts(window)
})
// IPC handlers
ipcMain.on('window-controls', (event, action) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (action === 'minimize') win.minimize()
if (action === 'maximize') win.isMaximized() ? win.unmaximize() : win.maximize()
if (action === 'close') win.close()
})
createWindow()
app.on('activate', function () {
+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);