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. -![Voicext Logo](resources/icon.png) +![Voicext Logo](docs/logo/voicext-logo.png) ## 🚀 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" + /> +
+ + +
+
+
+
+