Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ frontend/src-tauri/target/
.env.local
.env.*.local

# ─────────────────────────────────────────────────────────────────────────
# Build artifacts
# ─────────────────────────────────────────────────────────────────────────
*.msi

# ─────────────────────────────────────────────────────────────────────────
# OS junk
# ─────────────────────────────────────────────────────────────────────────
Expand Down
646 changes: 134 additions & 512 deletions README.md

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
import os
import sys

# Force offline mode before ANY library import touches the network.
# This must be set before huggingface_hub, transformers, or any HF
# library is imported — otherwise lazy imports deep in the stack can
# trigger httpx ConnectTimeout on restricted networks.
os.environ["HF_HUB_OFFLINE"] = "1"

# Triton is unavailable on Windows — disable torch.compile / dynamo / inductor
# to prevent TritonMissing errors at inference time. Must be set before
# torch is imported (lazy import in services/model_manager.py).
# Mirrors the PyInstaller runtime hook at backend/hooks/pyi_rth_torch_compiler_disable.py
if sys.platform == "win32":
os.environ.setdefault("TORCH_COMPILE_DISABLE", "1")
os.environ.setdefault("TORCHDYNAMO_DISABLE", "1")
os.environ.setdefault("TORCHINDUCTOR_DISABLE", "1")

# Ensure `backend/` is on sys.path so bare imports like `from core.config`
# work regardless of how uvicorn is invoked:
# - `uvicorn main:app` (cwd = backend/)
Expand Down
21 changes: 16 additions & 5 deletions backend/services/model_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,10 @@ def _on_hf_progress(ev):

lid = register_listener(_on_hf_progress)
try:
# Force offline mode so no sub-component accidentally hits the network.
_prev_hf_offline = os.environ.get("HF_HUB_OFFLINE")
os.environ["HF_HUB_OFFLINE"] = "1"

_set_loading("importing", "Importing PyTorch & OmniVoice runtime…")
logger.info("Importing PyTorch & OmniVoice runtime…")
torch = _lazy_torch()
Expand All @@ -293,15 +297,18 @@ def _on_hf_progress(ev):
logger.info("Skipping PyTorch Whisper preload; ASR will load on demand.")
_model = OmniVoice.from_pretrained(
checkpoint, device_map=device, dtype=torch.float16, load_asr=preload_asr,
local_files_only=True,
)

try:
if device == "cuda":
if device == "cuda" and not os.environ.get("TORCH_COMPILE_DISABLE"):
try:
_set_loading("compiling", "Compiling model (torch.compile)…")
_model.llm = torch.compile(_model.llm, mode="reduce-overhead")
logger.info("torch.compile applied.")
except Exception as e:
logger.info("torch.compile skipped: %s", e)
except Exception as e:
logger.info("torch.compile skipped: %s", e)
elif os.environ.get("TORCH_COMPILE_DISABLE"):
logger.info("torch.compile disabled by TORCH_COMPILE_DISABLE env var.")

_set_loading("ready", "Model ready", progress=100)
logger.info("OmniVoice model loaded successfully.")
Expand All @@ -312,6 +319,10 @@ def _on_hf_progress(ev):
logger.error("Model loading failed: %s", err_msg)
raise
finally:
if _prev_hf_offline is None:
os.environ.pop("HF_HUB_OFFLINE", None)
else:
os.environ["HF_HUB_OFFLINE"] = _prev_hf_offline
unregister_listener(lid)

async def get_model():
Expand All @@ -323,7 +334,7 @@ async def get_model():
async with _model_lock:
if model is None:
loop = asyncio.get_running_loop()
model = await loop.run_in_executor(_gpu_pool, _load_model_sync)
model = await loop.run_in_executor(_get_gpu_pool(), _load_model_sync)
return model


Expand Down
43 changes: 42 additions & 1 deletion frontend/src-tauri/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,48 @@ pub fn kill_orphan_on_port(port: u16) {
}

#[cfg(not(unix))]
pub fn kill_orphan_on_port(_port: u16) {}
pub fn kill_orphan_on_port(port: u16) {
// `netstat -ano` lists listening sockets with owning PID.
// Parse output to find the process listening on exactly `port`.
let out = match std::process::Command::new("netstat")
.args(["-ano", "-p", "TCP"])
.output()
{
Ok(o) => o,
Err(_) => return,
};
let stdout = String::from_utf8_lossy(&out.stdout);
// Match ":PORT " or ":PORT]" at end of address string to avoid false
// positives (e.g. :3900 matching port 39000).
let needle = format!(":{} ", port);
for line in stdout.lines() {
if !line.to_uppercase().contains("LISTENING") {
continue;
}
// Local address is the second whitespace-delimited field.
// Format: " TCP 0.0.0.0:3900 0.0.0.0:0 ..."
let local_addr = line.split_whitespace().nth(1).unwrap_or("");
if !local_addr.ends_with(&needle.trim_end()) {
// needle has trailing space trimmed; check suffix match
let port_suffix = format!(":{}", port);
if !local_addr.ends_with(&port_suffix) {
continue;
}
}
let parts: Vec<&str> = line.split_whitespace().collect();
if let Some(pid_str) = parts.last() {
if let Ok(pid) = pid_str.parse::<u32>() {
log::warn!(
"Killing orphan process {} on port {} (Windows)",
pid, port
);
let _ = std::process::Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.output();
Comment on lines +128 to +137
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don't taskkill arbitrary listeners on this port.

Line 135 force-kills whichever PID netstat reports, but this helper never verifies that the process is actually our backend. If some other local service is bound to the configured port, startup can terminate an unrelated app instead of surfacing a port conflict. Please check the process command line/executable matches the spawned OmniVoice backend before killing it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src-tauri/src/backend.rs` around lines 128 - 137, The current
Windows branch blindly taskkills whatever PID netstat reports; update the logic
around the PID extraction (the block that parses parts/ pid_str and produces pid
and the taskkill call) to first query the target process command line or
executable and verify it belongs to our spawned OmniVoice backend before
killing: after parsing pid, run a safe metadata lookup (e.g. via
PowerShell/Get-Process or wmic for the given pid) to obtain
ProcessName/Path/CommandLine, compare that value against the expected backend
executable name or unique marker used when we spawn the backend, and only call
std::process::Command::new("taskkill") if the verification matches; otherwise
log a port-conflict error and surface the conflict instead of killing the
process.

}
}
}
}

// ── Log paths ─────────────────────────────────────────────────────────────

Expand Down
63 changes: 35 additions & 28 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useState, useRef, useEffect, useCallback, Suspense, lazy } from 'react';
import { useTranslation } from 'react-i18next';
import './index.css';
import { useAppStore } from './store';
import SearchableSelect from './components/SearchableSelect';
Expand Down Expand Up @@ -43,7 +44,10 @@ import useProfiles from './hooks/useProfiles';
import useTTS from './hooks/useTTS';
import useDubWorkflow from './hooks/useDubWorkflow';

const LazyFallback = () => <div className="app-lazy-fallback">Loading…</div>;
const LazyFallback = () => {
const { t } = useTranslation();
return <div className="app-lazy-fallback">{t('common.loading')}</div>;
};

import { Toaster, toast } from 'react-hot-toast';
import {
Expand All @@ -59,6 +63,7 @@ import { exportAction, exportReveal, exportRecord } from './api/exports';
import { isTauri, doubleClickMaximize, fileToMediaUrl, playBlobAudio, playPing } from './utils/media';

function App() {
const { t } = useTranslation();
// First-run bootstrap: Rust spawns uv sync in a background thread and
// publishes progress via the `bootstrap_status` Tauri command. Hook below
// polls every 1 s; until `ready`, we render BootstrapSplash instead of the
Expand Down Expand Up @@ -208,7 +213,9 @@ function App() {
const [isCompareModalOpen, setIsCompareModalOpen] = useState(false);
const [compareVoiceA, setCompareVoiceA] = useState("");
const [compareVoiceB, setCompareVoiceB] = useState("");
const [compareText, setCompareText] = useState("The quick brown fox jumps over the lazy dog, proving that this voice sounds much better.");
const [compareText, setCompareText] = useState(() => {
try { return i18n?.t?.('voice.default_test_phrase'); } catch { return "The quick brown fox jumps over the lazy dog."; }
});
Comment on lines +216 to +218
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP '\bi18n\?\.t\?\(' frontend/src/App.jsx
rg -nP '^import .*\\bi18n\\b' frontend/src/App.jsx

Repository: debpalash/OmniVoice-Studio

Length of output: 52


🏁 Script executed:

#!/bin/bash
# Check if file exists and get line count
ls -la frontend/src/App.jsx 2>/dev/null || echo "File not found at frontend/src/App.jsx"
wc -l frontend/src/App.jsx 2>/dev/null || echo "Cannot count lines"

Repository: debpalash/OmniVoice-Studio

Length of output: 167


🏁 Script executed:

#!/bin/bash
# Read lines 216-218 and surrounding context
if [ -f frontend/src/App.jsx ]; then
  echo "=== Lines 210-225 ===" 
  sed -n '210,225p' frontend/src/App.jsx | cat -n
fi

Repository: debpalash/OmniVoice-Studio

Length of output: 985


🏁 Script executed:

#!/bin/bash
# Search for import statements related to i18n, translation, or useTranslation
if [ -f frontend/src/App.jsx ]; then
  echo "=== Import statements for i18n/translation ===" 
  head -50 frontend/src/App.jsx | grep -nE 'import.*i18n|import.*useTranslation|import.*translation'
fi

Repository: debpalash/OmniVoice-Studio

Length of output: 167


🏁 Script executed:

#!/bin/bash
# Check for all imports at the top of the file
if [ -f frontend/src/App.jsx ]; then
  echo "=== First 60 lines (imports section) ===" 
  head -60 frontend/src/App.jsx | cat -n
fi

Repository: debpalash/OmniVoice-Studio

Length of output: 3581


🏁 Script executed:

#!/bin/bash
# Search for useTranslation usage in App.jsx
rg -n 'useTranslation\|const \{ t \}' frontend/src/App.jsx | head -20

Repository: debpalash/OmniVoice-Studio

Length of output: 52


Undefined i18n reference prevents localized default compare text.

Line 217 references i18n without a binding. The useTranslation hook is imported but never instantiated in the App component, so the i18n object doesn't exist and the try/catch always falls back to English text.

Call useTranslation() to destructure the t function and use it directly:

Suggested fix
+  const { t } = useTranslation();
+
   const [compareText, setCompareText] = useState(() => {
-    try { return i18n?.t?.('voice.default_test_phrase'); } catch { return "The quick brown fox jumps over the lazy dog."; }
+    return t('voice.default_test_phrase', { defaultValue: 'The quick brown fox jumps over the lazy dog.' });
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const [compareText, setCompareText] = useState(() => {
try { return i18n?.t?.('voice.default_test_phrase'); } catch { return "The quick brown fox jumps over the lazy dog."; }
});
const { t } = useTranslation();
const [compareText, setCompareText] = useState(() => {
return t('voice.default_test_phrase', { defaultValue: 'The quick brown fox jumps over the lazy dog.' });
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/App.jsx` around lines 216 - 218, The default compareText
initializer references an undefined i18n instead of using the translation hook;
inside the App component call useTranslation() (e.g., const { t } =
useTranslation()) and update the useState initializer for compareText to use
t('voice.default_test_phrase') (with a try/catch or fallback string) so the
localized default is used; update any usages that referenced i18n.t to use the
destructured t function and ensure useTranslation is imported and invoked within
the App component scope.

const [compareResultA, setCompareResultA] = useState(null);
const [compareResultB, setCompareResultB] = useState(null);
const [isComparing, setIsComparing] = useState(false);
Expand Down Expand Up @@ -384,8 +391,8 @@ function App() {
const update = await check();
if (cancelled || !update) return;
const proceed = await ask(
`A new version (${update.version}) of OmniVoice Studio is available.\n\nWhat's new:\n${update.body || '— see release notes'}\n\nDownload and install now?`,
{ title: 'Update available', kind: 'info' },
t('settings.update_available_prompt', { version: update.version, body: update.body || t('settings.see_release_notes') }),
{ title: t('settings.update_available'), kind: 'info' },
);
if (!proceed) return;
await update.downloadAndInstall();
Expand Down Expand Up @@ -503,18 +510,18 @@ function App() {
if (!destPath) return; // User cancelled

await exportAction({ source_filename: sourceIdentifier, destination_path: destPath, mode });
toast.success(`Exported: ${fallbackName}`);
toast.success(t('settings.exported_file', { name: fallbackName }));
loadExportHistory();
} catch (err) {
console.error(err);
toast.error(`Export failed: ${err?.message || err}`);
toast.error(t('settings.export_failed', { msg: err?.message || err }));
}
};
const revealInFolder = async (filePath) => {
try {
await exportReveal({ path: filePath });
} catch (err) {
toast.error(`Could not open folder: ${err.message}`);
toast.error(t('settings.open_folder_failed', { msg: err.message }));
}
};
const parseFilenameFromContentDisposition = (header) => {
Expand All @@ -540,29 +547,29 @@ function App() {
filters: [{ name: modeGuess === 'video' ? 'Video' : 'Audio', extensions: [extGuess] }],
});
if (!destPath) return; // user cancelled
toast.loading(`Saving ${fallbackName}...`, { id: fallbackName });
toast.loading(t('settings.saving_file', { name: fallbackName }), { id: fallbackName });
const sep = url.includes('?') ? '&' : '?';
const res = await fetch(`${url}${sep}save_path=${encodeURIComponent(destPath)}`);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Save failed');
throw new Error(err.detail || t('settings.save_failed_generic'));
}
const data = await res.json();
toast.success(`Saved: ${data.path}`, { id: fallbackName });
toast.success(t('settings.saved_file', { path: data.path }), { id: fallbackName });
try {
await exportRecord({ filename: data.display_name || fallbackName, destination_path: data.path, mode: modeGuess });
loadExportHistory();
} catch (err) { console.warn('exportRecord (Tauri save path) failed:', err); }
} catch (err) {
console.error(err);
toast.error(`Save error: ${err.message}`, { id: fallbackName });
toast.error(t('settings.save_error', { msg: err.message }), { id: fallbackName });
}
return;
}

// Browser path: standard blob download.
try {
toast.loading(`Processing ${fallbackName}...`, { id: fallbackName });
toast.loading(t('settings.processing_file', { name: fallbackName }), { id: fallbackName });
const response = await fetch(url);
if (!response.ok) throw new Error("Download failed");
const serverName = parseFilenameFromContentDisposition(response.headers.get('content-disposition'));
Expand All @@ -576,14 +583,14 @@ function App() {
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(localUrl);
toast.success(`Downloaded ${finalName}`, { id: fallbackName });
toast.success(t('settings.downloaded_file', { name: finalName }), { id: fallbackName });
try {
await exportRecord({ filename: finalName, destination_path: `~/Downloads/${finalName}`, mode: modeGuess });
loadExportHistory();
} catch (err) { console.warn('exportRecord (browser download path) failed:', err); }
} catch (err) {
console.error(err);
toast.error(`Download error: ${err.message}`, { id: fallbackName });
toast.error(t('settings.download_error', { msg: err.message }), { id: fallbackName });
}
};
// Pre-flight for audio/video exports. If any segments are at preview
Expand All @@ -592,7 +599,7 @@ function App() {
// artifacts. No-op when previewSegIds is empty.
const finalizeTtsBeforeExport = async () => {
if (!previewSegIds || previewSegIds.length === 0) return;
toast(`Upgrading ${previewSegIds.length} preview-quality segment${previewSegIds.length === 1 ? '' : 's'} to full quality…`, { icon: '✨' });
toast(t('dub.upgrading_preview_segments', { n: previewSegIds.length }), { icon: '✨' });
await handleDubGenerate({ regenOnly: previewSegIds, preview: false });
};
const handleDubDownload = async () => {
Expand Down Expand Up @@ -631,10 +638,10 @@ function App() {
// ═══ STUDIO PROJECT CRUD ═══
const saveProject = async () => {
if (dubStep === 'idle') {
toast.error("Please click 'Upload & Transcribe' first so the video is processed on the server before saving.");
toast.error(t('toast.save_dub_first'));
return;
}
const name = activeProjectName || dubFilename || `Project ${new Date().toLocaleString()}`;
const name = activeProjectName || dubFilename || t('dub.untitled_project');
const statePayload = {
name,
video_path: dubFilename || null,
Expand All @@ -649,10 +656,10 @@ function App() {
try {
const data = await apiSaveProject(statePayload, activeProjectId);
setActiveProject(data.id, name);
toast.success(activeProjectId ? 'Project saved' : 'Project created');
toast.success(t('toast.project_saved'));
loadProjects();
} catch (err) {
toast.error('Save failed: ' + err.message);
toast.error(t('voice.save_failed', { msg: err.message }));
}
};

Expand Down Expand Up @@ -680,22 +687,22 @@ function App() {
// the last generate.
setLastGenFingerprints(s.segHashes || {});
setSpeakerClones(s.speakerClones || {});
toast.success(`Opened: ${data.name}`);
toast.success(t('sidebar.load_profile', { name: data.name }));
} catch (err) {
toast.error(err.message);
}
};

const deleteProject = async (projectId, e) => {
if (e) e.stopPropagation();
if (!(await askConfirm('Delete this project? This cannot be undone.'))) return;
if (!(await askConfirm(t('dub.delete_project_confirm')))) return;
try {
await apiDeleteProject(projectId);
if (activeProjectId === projectId) {
setActiveProject(null);
}
loadProjects();
toast.success('Project deleted');
toast.success(t('dub.project_deleted'));
} catch (err) { toast.error(err.message); }
};

Expand Down Expand Up @@ -735,11 +742,11 @@ function App() {

// Switch to studio tab
setSidebarTab('projects');
toast.success('Restored previous generation state');
toast.success(t('sidebar.history_restored'));
};

const deleteHistory = async (id, type) => {
if (!(await askConfirm('Delete this history item?'))) return;
if (!(await askConfirm(t('sidebar.delete_history_confirm')))) return;
try {
const endpoint = type === 'dub' ? `${API}/dub/history/${id}` : `${API}/history/${id}`;
await fetch(endpoint, { method: 'DELETE' });
Expand All @@ -748,7 +755,7 @@ function App() {
} else {
loadHistory();
}
toast.success('History item deleted');
toast.success(t('sidebar.history_item_deleted'));
} catch (err) {
toast.error(err.message);
}
Expand Down Expand Up @@ -827,7 +834,7 @@ function App() {
file={pendingTrimFile}
maxSeconds={CLONE_MAX_SECONDS}
onCancel={() => setPendingTrimFile(null)}
onConfirm={(trimmed) => { setPendingTrimFile(null); setRefAudio(trimmed); setSelectedProfile(null); toast.success('Trimmed audio loaded'); }}
onConfirm={(trimmed) => { setPendingTrimFile(null); setRefAudio(trimmed); setSelectedProfile(null); toast.success(t('voice.trimmed_audio_loaded')); }}
/>
</Suspense>
</ErrorBoundary>
Expand All @@ -849,8 +856,8 @@ function App() {
onFlushMemory={async (unloadModel) => {
try {
const r = await apiFlushMemory(unloadModel);
toast.success(`Flushed — RAM ${r.ram_after}G · VRAM ${r.vram_after}G${r.unloaded_model ? ' · model unloaded' : ''}`);
} catch (e) { toast.error('Flush failed: ' + e.message); }
toast.success(t('settings.flushed_memory', { ram: r.ram_after, vram: r.vram_after, unloaded: r.unloaded_model ? ` · ${t('settings.model_unloaded')}` : '' }));
} catch (e) { toast.error(t('settings.flush_failed', { msg: e.message })); }
}}
/>

Expand Down
Loading