diff --git a/README.md b/README.md
index 478fef4..d2fce82 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
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
diff --git a/src/main/index.js b/src/main/index.js
index e852007..bad5a7f 100644
--- a/src/main/index.js
+++ b/src/main/index.js
@@ -1,9 +1,10 @@
import { app, shell, BrowserWindow, ipcMain } from 'electron'
import { join } from 'path'
+import fs from 'fs'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
-import { detectHardware, transcribe } from './engine/transcription'
+import { detectHardware, transcribe, fixSrt } from './engine/transcription'
function createWindow() {
const mainWindow = new BrowserWindow({
@@ -76,6 +77,52 @@ function createWindow() {
ipcMain.on('open-explorer', (event, path) => {
shell.showItemInFolder(path)
})
+
+ // Handle SRT Reading
+ ipcMain.handle('read-srt', async (event, filePath) => {
+ try {
+ const content = fs.readFileSync(filePath, 'utf-8')
+ return content
+ } catch (error) {
+ console.error('Read SRT Error:', error)
+ throw error
+ }
+ })
+
+ // Handle SRT Saving
+ ipcMain.handle('save-srt', async (event, { filePath, content }) => {
+ try {
+ fs.writeFileSync(filePath, content, 'utf-8')
+ return { success: true }
+ } catch (error) {
+ console.error('Save SRT Error:', error)
+ throw error
+ }
+ })
+
+ // Handle File Dialog
+ ipcMain.handle('select-file', async (event, filters) => {
+ const { dialog } = require('electron')
+ const result = await dialog.showOpenDialog({
+ properties: ['openFile'],
+ filters: filters || []
+ })
+ if (result.canceled) return null
+ return result.filePaths[0]
+ })
+
+ // Handle SRT Optimization
+ ipcMain.handle('optimize-srt', async (event, filePath) => {
+ try {
+ const content = fs.readFileSync(filePath, 'utf-8')
+ const optimized = fixSrt(content)
+ fs.writeFileSync(filePath, optimized, 'utf-8')
+ return { success: true, content: optimized }
+ } catch (error) {
+ console.error('Optimize SRT Error:', error)
+ throw error
+ }
+ })
}
app.whenReady().then(() => {
diff --git a/src/preload/index.js b/src/preload/index.js
index 87e2734..2b9cf1f 100644
--- a/src/preload/index.js
+++ b/src/preload/index.js
@@ -7,7 +7,11 @@ const api = {
onTranscriptionProgress: (callback) => ipcRenderer.on('transcription-progress', (_, progress) => callback(progress)),
onTranscriptionData: (callback) => ipcRenderer.on('transcription-data', (_, data) => callback(data)),
detectHardware: () => ipcRenderer.invoke('detect-hardware'),
- openExplorer: (path) => ipcRenderer.send('open-explorer', path)
+ openExplorer: (path) => ipcRenderer.send('open-explorer', path),
+ readSrt: (filePath) => ipcRenderer.invoke('read-srt', filePath),
+ saveSrt: (filePath, content) => ipcRenderer.invoke('save-srt', { filePath, content }),
+ selectFile: (filters) => ipcRenderer.invoke('select-file', filters),
+ optimizeSrt: (filePath) => ipcRenderer.invoke('optimize-srt', filePath)
}
if (process.contextIsolated) {
diff --git a/src/renderer/src/App.jsx b/src/renderer/src/App.jsx
index efdc817..e5d3f0d 100644
--- a/src/renderer/src/App.jsx
+++ b/src/renderer/src/App.jsx
@@ -18,6 +18,7 @@ import logo from './assets/logo.png'
import CustomSelect from './components/CustomSelect'
import ResultModal from './components/ResultModal'
+import SrtEditor from './components/SrtEditor'
const languages = [
{ id: 'auto', name: 'AUTO (Recommended)' },
@@ -51,6 +52,7 @@ function App() {
format: 'srt'
})
const [showResult, setShowResult] = useState(null)
+ const [selectedSrt, setSelectedSrt] = useState(null)
useEffect(() => {
if (!window.api) {
@@ -120,6 +122,14 @@ function App() {
}
}
+ const handleSelectSrt = async () => {
+ const filters = [{ name: 'Subtitle Files', extensions: ['srt'] }]
+ const path = await window.api.selectFile(filters)
+ if (path) {
+ setSelectedSrt(path)
+ }
+ }
+
return (
{/* Titlebar */}
@@ -296,6 +306,35 @@ function App() {
)}
+
+ {activeTab === 'queue' && (
+
+ {selectedSrt ? (
+ setSelectedSrt(null)} />
+ ) : (
+
+
+
+
SRT EDITOR & QUEUE
+
Select an SRT file to fix timestamps and edit text.
+
+
+
+ )}
+
+ )}
@@ -318,6 +357,10 @@ function App() {
isOpen={!!showResult}
path={showResult}
onClose={() => setShowResult(null)}
+ onEdit={(path) => {
+ setSelectedSrt(path)
+ setActiveTab('queue')
+ }}
/>
)
diff --git a/src/renderer/src/components/ResultModal.jsx b/src/renderer/src/components/ResultModal.jsx
index e1d8230..16efa7f 100644
--- a/src/renderer/src/components/ResultModal.jsx
+++ b/src/renderer/src/components/ResultModal.jsx
@@ -1,8 +1,8 @@
import React from 'react'
import { motion, AnimatePresence } from 'framer-motion'
-import { CheckCircle2, FolderOpen, X } from 'lucide-react'
+import { CheckCircle2, FolderOpen, X, FileEdit } from 'lucide-react'
-export default function ResultModal({ isOpen, path, onClose }) {
+export default function ResultModal({ isOpen, path, onClose, onEdit }) {
const handleOpenFolder = () => {
if (window.api && window.api.openExplorer) {
window.api.openExplorer(path)
@@ -45,6 +45,12 @@ export default function ResultModal({ isOpen, path, onClose }) {
OPEN FOLDER
+ {path && path.toLowerCase().endsWith('.srt') && (
+
+ )}
diff --git a/src/renderer/src/components/SrtEditor.jsx b/src/renderer/src/components/SrtEditor.jsx
new file mode 100644
index 0000000..d61dbe9
--- /dev/null
+++ b/src/renderer/src/components/SrtEditor.jsx
@@ -0,0 +1,212 @@
+import React, { useState, useEffect, useRef } from 'react'
+import { Save, Download, Zap, ChevronUp, ChevronDown, CheckCircle, ArrowLeft } from 'lucide-react'
+import { motion, AnimatePresence } from 'framer-motion'
+
+const SrtEditor = ({ filePath, onClose }) => {
+ const [segments, setSegments] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+ const [fileName, setFileName] = useState('')
+ const scrollRef = useRef(null)
+
+ useEffect(() => {
+ if (filePath) {
+ loadSrt(filePath)
+ setFileName(filePath.split(/[\\/]/).pop())
+ }
+ }, [filePath])
+
+ const loadSrt = async (path) => {
+ setLoading(true)
+ try {
+ const content = await window.api.readSrt(path)
+ const parsed = parseSrt(content)
+ setSegments(parsed)
+ } catch (error) {
+ alert('Error loading SRT: ' + error.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const parseSrt = (content) => {
+ const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
+ const blocks = normalized.trim().split(/\n\n+/)
+ return blocks.map((block, idx) => {
+ const lines = block.trim().split('\n')
+ if (lines.length < 2) return null
+
+ const timeLineIdx = lines.findIndex(l => l.includes('-->'))
+ if (timeLineIdx === -1) return null
+
+ const times = lines[timeLineIdx].split('-->').map(t => t.trim())
+ const text = lines.slice(timeLineIdx + 1).join('\n')
+
+ return {
+ id: idx,
+ start: times[0],
+ end: times[1],
+ text: text
+ }
+ }).filter(Boolean)
+ }
+
+ const stringifySrt = (segs) => {
+ return segs.map((s, i) => {
+ return `${i + 1}\n${s.start} --> ${s.end}\n${s.text}`
+ }).join('\n\n') + '\n'
+ }
+
+ const handleSave = async () => {
+ setSaving(true)
+ try {
+ const content = stringifySrt(segments)
+ await window.api.saveSrt(filePath, content)
+ // Show some success feedback
+ } catch (error) {
+ alert('Error saving SRT: ' + error.message)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleOptimize = async () => {
+ setSaving(true)
+ try {
+ // First save current state
+ const currentContent = stringifySrt(segments)
+ await window.api.saveSrt(filePath, currentContent)
+
+ // Then optimize
+ const result = await window.api.optimizeSrt(filePath)
+ if (result.success) {
+ const parsed = parseSrt(result.content)
+ setSegments(parsed)
+ }
+ } catch (error) {
+ alert('Error optimizing SRT: ' + error.message)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const updateSegment = (id, field, value) => {
+ setSegments(prev => prev.map(s => s.id === id ? { ...s, [field]: value } : s))
+ }
+
+ const handleKeyDown = (e) => {
+ if (e.shiftKey && e.key === 'S') {
+ e.preventDefault()
+ handleSave()
+ }
+ }
+
+ useEffect(() => {
+ window.addEventListener('keydown', handleKeyDown)
+ return () => window.removeEventListener('keydown', handleKeyDown)
+ }, [segments])
+
+ if (loading) return Loading SRT...
+
+ return (
+
+
+
+
+ FILE:
+ {fileName}
+ (Preview)
+
+
+
+
+
+
Index
+
Start
+
End
+
Subtitle Text
+
+
+ {segments.map((seg, idx) => (
+
+ {(idx + 1).toString().padStart(2, '0')}
+
+
+
updateSegment(seg.id, 'start', e.target.value)}
+ className="time-input"
+ />
+
+
+
+
+
+
+
+
+
updateSegment(seg.id, 'end', e.target.value)}
+ className="time-input"
+ />
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default SrtEditor
diff --git a/src/renderer/src/index.css b/src/renderer/src/index.css
index 14fd48c..211b0c6 100644
--- a/src/renderer/src/index.css
+++ b/src/renderer/src/index.css
@@ -598,3 +598,269 @@ body {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
}
+
+/* SRT Editor Styles */
+.srt-editor-container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ padding: 25px;
+ position: relative;
+ overflow: hidden;
+ border: 1px solid rgba(0, 242, 255, 0.2);
+}
+
+.editor-header {
+ margin-bottom: 20px;
+ display: flex;
+ align-items: center;
+ gap: 15px;
+}
+
+.btn-back {
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid var(--glass-border);
+ color: var(--text-muted);
+ width: 36px;
+ height: 36px;
+ border-radius: 10px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.btn-back:hover {
+ background: rgba(255, 255, 255, 0.1);
+ color: white;
+ border-color: rgba(255, 255, 255, 0.2);
+}
+
+.file-info {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 18px;
+}
+
+.file-label {
+ color: var(--text-muted);
+ font-weight: 500;
+}
+
+.file-name {
+ color: var(--primary-cyan);
+ font-weight: 600;
+}
+
+.preview-label {
+ color: var(--text-muted);
+ font-size: 14px;
+}
+
+.segments-list {
+ flex: 1;
+ overflow-y: auto;
+ padding-right: 10px;
+ margin-bottom: 20px;
+}
+
+.segments-list::-webkit-scrollbar {
+ width: 6px;
+}
+
+.segments-list::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.segments-list::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 10px;
+}
+
+.segments-header {
+ display: grid;
+ grid-template-columns: 60px 120px 120px 1fr;
+ padding: 10px 15px;
+ color: var(--text-muted);
+ font-size: 14px;
+ font-weight: 500;
+ border-bottom: 1px solid var(--glass-border);
+ margin-bottom: 10px;
+}
+
+.segment-row {
+ display: grid;
+ grid-template-columns: 60px 120px 120px 1fr;
+ align-items: center;
+ padding: 8px 15px;
+ border-radius: 12px;
+ margin-bottom: 8px;
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid transparent;
+ transition: all 0.2s;
+}
+
+.segment-row:hover {
+ background: rgba(255, 255, 255, 0.05);
+ border-color: rgba(255, 255, 255, 0.1);
+}
+
+.segment-row:focus-within {
+ border-color: var(--primary-cyan);
+ background: rgba(0, 242, 255, 0.03);
+ box-shadow: 0 0 15px rgba(0, 242, 255, 0.1);
+}
+
+.col-index {
+ font-size: 14px;
+ color: var(--text-muted);
+ font-family: monospace;
+}
+
+.time-input-container {
+ position: relative;
+ width: 100px;
+}
+
+.time-input {
+ width: 100%;
+ background: rgba(0, 0, 0, 0.2);
+ border: 1px solid var(--glass-border);
+ border-radius: 8px;
+ padding: 6px 10px;
+ color: var(--text-main);
+ font-size: 13px;
+ font-family: monospace;
+ outline: none;
+}
+
+.time-controls {
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ transform: translateY(-50%);
+ display: flex;
+ flex-direction: column;
+ color: var(--text-muted);
+ opacity: 0.5;
+}
+
+.text-input {
+ width: 100%;
+ background: rgba(0, 0, 0, 0.2);
+ border: 1px solid var(--glass-border);
+ border-radius: 8px;
+ padding: 8px 12px;
+ color: var(--text-main);
+ font-size: 14px;
+ resize: none;
+ outline: none;
+ transition: all 0.2s;
+}
+
+.text-input:focus {
+ border-color: var(--primary-cyan);
+ background: rgba(0, 0, 0, 0.4);
+}
+
+.editor-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 20px;
+ padding-top: 20px;
+ border-top: 1px solid var(--glass-border);
+}
+
+.footer-actions-left {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.btn-footer-secondary {
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid var(--glass-border);
+ border-radius: 12px;
+ padding: 10px 20px;
+ color: var(--text-muted);
+ font-size: 13px;
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ cursor: pointer;
+ transition: all 0.2s;
+ text-align: left;
+}
+
+.btn-footer-secondary:hover {
+ background: rgba(255, 255, 255, 0.08);
+ color: white;
+ border-color: rgba(255, 255, 255, 0.2);
+}
+
+.btn-subtext {
+ font-size: 10px;
+ opacity: 0.6;
+ font-weight: 400;
+ display: block;
+}
+
+.btn-one-click-export {
+ flex: 1;
+ max-width: 450px;
+ height: 80px;
+ background: linear-gradient(135deg, #4b1d3f 0%, #1d1b4b 100%);
+ border: 2px solid #ff00e5;
+ border-radius: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 30px;
+ color: white;
+ cursor: pointer;
+ transition: all 0.3s;
+ position: relative;
+ box-shadow: 0 0 30px rgba(255, 0, 229, 0.2);
+}
+
+.btn-one-click-export:hover {
+ transform: translateY(-3px) scale(1.02);
+ box-shadow: 0 0 50px rgba(255, 0, 229, 0.4);
+ filter: brightness(1.2);
+}
+
+.btn-one-click-export .btn-content {
+ display: flex;
+ flex-direction: column;
+ text-align: left;
+}
+
+.btn-one-click-export .main-text {
+ font-size: 18px;
+ font-weight: 800;
+ letter-spacing: 0.5px;
+}
+
+.btn-one-click-export .sub-text {
+ font-size: 11px;
+ opacity: 0.8;
+ margin-top: 2px;
+}
+
+.btn-one-click-export .btn-icon {
+ color: #ffaa00;
+ filter: drop-shadow(0 0 8px #ffaa00);
+}
+
+.editor-loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ font-size: 20px;
+ color: var(--primary-cyan);
+}