feat: implement file export history, native folder reveal, and robust FFmpeg/torchcodec environment management.

This commit is contained in:
debpalash
2026-04-14 19:40:26 +05:30
parent d79b6f6d04
commit cd8cbe6243
17 changed files with 568 additions and 83 deletions
+169 -25
View File
@@ -14,10 +14,31 @@ from contextlib import asynccontextmanager
from typing import Optional, List
from concurrent.futures import ThreadPoolExecutor
import numpy as np
import os
import sys
import site
import shutil
# [CRITICAL PRODUCTION PATCH]
# Systematically purge 'torchcodec' from the environment before PyTorch loads.
# This violently neutralizes PyTorch's broken dynamic linkage sequence against
# fragile Homebrew FFmpeg (.dylibs) on macOS, mandating a safe fallback to `soundfile`.
for sp in [sys.prefix] + site.getsitepackages():
if "site-packages" not in sp: sp = os.path.join(sp, "lib", f"python3.{sys.version_info.minor}", "site-packages")
tc_path = os.path.join(sp, "torchcodec")
if os.path.exists(tc_path):
try: shutil.rmtree(tc_path); print("Sanitized broken torchcodec module.")
except: pass
import soundfile as sf
import torch
import torchaudio
# Enforce fully static soundfile backend
import warnings
warnings.filterwarnings("ignore", category=UserWarning)
torchaudio.set_audio_backend("soundfile")
from fastapi import FastAPI, File, Form, UploadFile, HTTPException, Query
from fastapi.responses import FileResponse, Response, StreamingResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
@@ -192,6 +213,13 @@ def _init_db():
created_at REAL,
updated_at REAL
);
CREATE TABLE IF NOT EXISTS export_history (
id TEXT PRIMARY KEY,
filename TEXT,
destination_path TEXT,
mode TEXT,
created_at REAL
);
""")
# Safe migrations for existing databases
for col, typedef in [
@@ -281,6 +309,15 @@ from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="OmniVoice Studio API", version="0.4.0", lifespan=lifespan)
from fastapi import Request
import traceback
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
with open("crash_log.txt", "w") as f:
f.write(f"Request: {request.url}\n")
f.write(traceback.format_exc())
return JSONResponse({"detail": str(exc)}, status_code=500)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], allow_credentials=True,
@@ -552,9 +589,102 @@ async def clean_audio(audio: UploadFile = File(...)):
# ═══════════════════════════════════════════════════════════════════════
# GENERATION HISTORY (SQLite + disk)
# GENERATION HISTORY & EXPORTS (SQLite + disk)
# ═══════════════════════════════════════════════════════════════════════
class ExportRequest(BaseModel):
source_filename: str
destination_path: str
mode: str = "history"
@app.post("/export")
def export_file(req: ExportRequest):
src_paths = [
os.path.join(OUTPUTS_DIR, req.source_filename),
os.path.join("dub/outputs", req.source_filename)
]
found = False
for sp in src_paths:
if os.path.exists(sp):
try:
shutil.copy2(sp, req.destination_path)
found = True
break
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
if not found:
raise HTTPException(status_code=404, detail="Source file not found")
export_id = str(uuid.uuid4())[:8]
conn = _get_db()
conn.execute(
"INSERT INTO export_history (id, filename, destination_path, mode, created_at) VALUES (?, ?, ?, ?, ?)",
(export_id, req.source_filename, req.destination_path, req.mode, time.time())
)
conn.commit()
conn.close()
return {"success": True, "id": export_id}
class ExportRecordRequest(BaseModel):
filename: str
destination_path: str = "~/Downloads"
mode: str = "file"
@app.post("/export/record")
def record_export(req: ExportRecordRequest):
"""Record a blob-based download in export history (no file copy)."""
export_id = str(uuid.uuid4())[:8]
conn = _get_db()
conn.execute(
"INSERT INTO export_history (id, filename, destination_path, mode, created_at) VALUES (?, ?, ?, ?, ?)",
(export_id, req.filename, req.destination_path, req.mode, time.time())
)
conn.commit()
conn.close()
return {"success": True, "id": export_id}
@app.get("/export/history")
def get_export_history():
conn = _get_db()
rows = conn.execute("SELECT * FROM export_history ORDER BY created_at DESC LIMIT 50").fetchall()
conn.close()
return [dict(r) for r in rows]
class RevealRequest(BaseModel):
path: str
@app.post("/export/reveal")
def reveal_in_folder(req: RevealRequest):
"""Open the containing folder of a file in the native OS file manager."""
import platform
target = os.path.expanduser(req.path)
# If the path is a file, reveal it selected; if dir, just open it
folder = target if os.path.isdir(target) else os.path.dirname(target)
system = platform.system()
try:
if system == "Darwin":
# macOS: open Finder with file selected
if os.path.isfile(target):
subprocess.Popen(["open", "-R", target])
else:
subprocess.Popen(["open", folder])
elif system == "Windows":
# Windows: Explorer with file selected
if os.path.isfile(target):
subprocess.Popen(["explorer", "/select,", target.replace("/", "\\")])
else:
subprocess.Popen(["explorer", folder.replace("/", "\\")])
else:
# Linux: xdg-open the containing folder
subprocess.Popen(["xdg-open", folder])
return {"success": True}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/history")
def list_history():
conn = _get_db()
@@ -798,18 +928,18 @@ def _get_diarization_pipeline():
return None
def _find_ffmpeg():
for path in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "ffmpeg"]:
if shutil.which(path):
return path
raise RuntimeError("ffmpeg not found")
try:
import imageio_ffmpeg
# This will natively extract and return an architecture-specific static FFmpeg binary!
return imageio_ffmpeg.get_ffmpeg_exe()
except Exception as e:
logger.warning(f"imageio_ffmpeg failed to provide static binary: {e}. Falling back to default system path.")
for path in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "ffmpeg"]:
if shutil.which(path):
return path
raise RuntimeError("ffmpeg not found in bundle or system path")
def _find_ffprobe():
for path in ["/opt/homebrew/bin/ffprobe", "/usr/local/bin/ffprobe", "ffprobe"]:
if shutil.which(path):
return path
raise RuntimeError("ffprobe not found")
# ── Preview file proxy (avoids blob: URLs which fail in Tauri's WebKit) ────────
PREVIEW_DIR = os.path.join(DATA_DIR, "preview")
@@ -887,17 +1017,8 @@ async def dub_upload(video: UploadFile = File(...)):
except Exception as e:
raise HTTPException(status_code=500, detail=f"ffmpeg failed: {str(e)}")
ffprobe = _find_ffprobe()
try:
proc = await asyncio.create_subprocess_exec(
ffprobe, "-v", "error", "-show_entries", "format=duration",
"-of", "json", video_path,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, _ = await proc.communicate()
if proc.returncode != 0:
raise Exception("ffprobe failed")
dur = float(json.loads(stdout.decode())["format"]["duration"])
dur = float(sf.info(audio_path).frames) / float(sf.info(audio_path).samplerate)
except Exception:
dur = 0.0
@@ -984,15 +1105,19 @@ async def dub_transcribe(job_id: str):
def _transcribe():
import re
# Load pure vocal audio as numpy array for vastly improved Whisper accuracy
asr_audio_target = job.get("vocals_path", job.get("audio_path"))
import traceback
# Safe fallback check if user omitted Demucs or the pipeline completely crashed during source vocal separation!
asr_audio_target = job.get("vocals_path")
if not asr_audio_target or not os.path.exists(asr_audio_target):
asr_audio_target = job.get("audio_path")
audio_np, sr = sf.read(asr_audio_target, dtype="float32")
if audio_np.ndim > 1:
audio_np = audio_np.mean(axis=1)
audio_input = {"array": audio_np, "sampling_rate": sr}
bs = 16 if torch.cuda.is_available() else (2 if torch.backends.mps.is_available() else 1)
# Use chunk-level timestamps
result = _model._asr_pipe(
audio_input, return_timestamps=True,
@@ -1251,6 +1376,25 @@ async def dub_generate(job_id: str, req: DubRequest):
# Calculate lip-sync score natively from tensor
generated_dur = audio_tensor.shape[-1] / _model.sampling_rate
sync_ratio = round(generated_dur / max(seg_duration, 0.01), 3)
# Auto time-stretch to fit segment window if off by >5%
if sync_ratio > 1.05 or sync_ratio < 0.95:
target_samples = int(seg_duration * _model.sampling_rate)
current_samples = audio_tensor.shape[-1]
if target_samples > 0 and current_samples > 0:
# Resample to effectively time-stretch: change "perceived" sample rate
# then resample back to actual rate
stretch_ratio = current_samples / target_samples
# Use interpolation for clean time-stretching
audio_tensor = torch.nn.functional.interpolate(
audio_tensor.unsqueeze(0), # add batch dim
size=target_samples,
mode='linear',
align_corners=False,
).squeeze(0) # remove batch dim
generated_dur = audio_tensor.shape[-1] / _model.sampling_rate
sync_ratio = round(generated_dur / max(seg_duration, 0.01), 3)
sync_scores.append(sync_ratio)
# Save individual segment WAV for preview
+3
View File
@@ -12,6 +12,7 @@
"name": "omnivoice-studio",
"version": "0.0.0",
"dependencies": {
"@tauri-apps/plugin-dialog": "^2.7.0",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
@@ -168,6 +169,8 @@
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg=="],
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.0", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aalBeSl4agT/QtYGDyf/XLajedWzUC9Vg/pm/YO6QQ93vkQ91Vz5uK1ta5RbVRDozQSz4njxUNqRNmOXDzW+qw=="],
+1
View File
@@ -11,6 +11,7 @@
"preview": "vite preview"
},
"dependencies": {
"@tauri-apps/plugin-dialog": "^2.7.0",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
+68
View File
@@ -84,6 +84,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-log",
]
@@ -2177,6 +2178,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.11.0",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -2884,6 +2886,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "rkyv"
version = "0.7.46"
@@ -3668,6 +3694,48 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 0.9.12+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-log"
version = "2.8.0"
+1
View File
@@ -23,3 +23,4 @@ serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.10.3", features = ["macos-private-api", "protocol-asset"] }
tauri-plugin-log = "2"
tauri-plugin-dialog = "2"
+5 -1
View File
@@ -11,6 +11,10 @@
"core:window:allow-toggle-maximize",
"core:window:allow-set-fullscreen",
"core:window:allow-minimize",
"core:window:allow-close"
"core:window:allow-close",
"dialog:allow-save",
"dialog:allow-open",
"dialog:allow-message",
"dialog:allow-ask"
]
}
+1
View File
@@ -16,6 +16,7 @@ fn port_in_use(port: u16) -> bool {
pub fn run() {
tauri::Builder::default()
.setup(|app| {
app.handle().plugin(tauri_plugin_dialog::init())?;
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
+221 -42
View File
@@ -10,7 +10,7 @@ import {
FileText, Loader, Check, AlertCircle, Plus, User, Save, Languages, Headphones,
FolderOpen, FolderPlus, Pencil, Clock, Lock, Unlock, Mic, MicOff, Square,
CheckCircle, Circle, ChevronRight, Target, PanelLeftClose, PanelLeftOpen, Scale,
Layers, Music, Package
Layers, Music, Package, DownloadCloud
} from 'lucide-react';
// Tauri: pre-import window API to avoid async delays in event handlers
@@ -175,9 +175,10 @@ function App() {
const [langSearch, setLangSearch] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [history, setHistory] = useState([]);
const [exportHistory, setExportHistory] = useState([]);
const [speed, setSpeed] = useState(1.0);
const [steps, setSteps] = useState(16);
const [steps, setSteps] = useState(16); // Must be ~16 to prevent ODE destabilization
const [cfg, setCfg] = useState(2.0);
const [showOverrides, setShowOverrides] = useState(false);
const [denoise, setDenoise] = useState(true);
@@ -349,6 +350,13 @@ function App() {
} catch (e) {}
}, []);
const loadExportHistory = useCallback(async () => {
try {
const res = await fetch(`${API}/export/history`);
if (res.ok) setExportHistory(await res.json());
} catch (e) {}
}, []);
useEffect(() => {
// Wait for backend to come alive before loading data (handles Tauri startup race)
let cancelled = false;
@@ -368,6 +376,7 @@ function App() {
loadHistory();
loadDubHistory();
loadProjects();
loadExportHistory();
};
loadAll();
// Restore local UI state
@@ -905,14 +914,71 @@ function App() {
} catch (err) { setDubError(err.message); setDubStep('editing'); }
};
const triggerDownload = (url, fallbackName) => {
const a = document.createElement('a');
a.href = url;
a.download = fallbackName || 'download';
a.target = '_blank';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
const handleNativeExport = async (e, sourceIdentifier, fallbackName, mode) => {
if (e) { e.preventDefault(); e.stopPropagation(); }
try {
const { save } = await import('@tauri-apps/plugin-dialog');
const ext = fallbackName.includes('.') ? fallbackName.split('.').pop() : 'wav';
const destPath = await save({ defaultPath: fallbackName, filters: [{ name: 'Media', extensions: [ext] }] });
if (!destPath) return; // User cancelled
// Tell Python backend to natively execute the copy bypassing Blob serialization!
const res = await fetch(`${API}/export`, {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ source_filename: sourceIdentifier, destination_path: destPath, mode })
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Export failed');
}
const data = await res.json();
toast.success(`Exported: ${fallbackName}`);
loadExportHistory();
} catch (err) {
console.error(err);
toast.error('Failed to bridge save dialog to rust/python backend.');
}
};
const revealInFolder = async (filePath) => {
try {
const res = await fetch(`${API}/export/reveal`, {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ path: filePath })
});
if (!res.ok) throw new Error('Failed to open folder');
} catch (err) {
toast.error(`Could not open folder: ${err.message}`);
}
};
const triggerDownload = async (url, fallbackName) => {
try {
toast.loading(`Processing ${fallbackName}...`, { id: fallbackName });
const response = await fetch(url);
if (!response.ok) throw new Error("Download failed");
const blob = await response.blob();
const localUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = localUrl;
a.download = fallbackName || 'download';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(localUrl);
toast.success(`Downloaded ${fallbackName}`, { id: fallbackName });
// Record to export history
try {
const ext = fallbackName.split('.').pop() || '';
const mode = ['mp4','mov','mkv','webm'].includes(ext) ? 'video' : ['wav','mp3','flac'].includes(ext) ? 'audio' : 'file';
await fetch(`${API}/export/record`, {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ filename: fallbackName, destination_path: `~/Downloads/${fallbackName}`, mode })
});
loadExportHistory();
} catch (_) {}
} catch (err) {
console.error(err);
toast.error(`Download error: ${err.message}`, { id: fallbackName });
}
};
const handleDubDownload = () => {
// Build selected tracks from all known tracks, matching the checkbox `!== false` logic
@@ -973,9 +1039,10 @@ function App() {
}
};
const loadProject = async (project) => {
const loadProject = async (projectOrId) => {
const pid = typeof projectOrId === 'string' ? projectOrId : projectOrId?.id;
try {
const res = await fetch(`${API}/projects/${project.id}`);
const res = await fetch(`${API}/projects/${pid}`);
if (!res.ok) throw new Error('Failed to load project');
const data = await res.json();
const s = data.state || {};
@@ -2034,32 +2101,42 @@ function App() {
{/* ── SIDEBAR ── */}
{
<div className="glass-panel history-panel" style={{display:'flex', flexDirection:'column'}}>
<div style={{display:'flex', gap:'4px', padding:'6px', borderBottom:'1px solid var(--glass-border)', background:'rgba(0,0,0,0.15)', flexShrink:0}}>
<div style={{display:'flex', gap:0, padding:0, borderBottom:'1px solid var(--glass-border)', background:'rgba(0,0,0,0.2)', flexShrink:0, flexDirection: isSidebarCollapsed ? 'column' : 'row'}}>
<button onClick={() => setSidebarTab('projects')} style={{
flex:1, padding: isSidebarCollapsed ? '4px 0' : '4px 6px', fontSize:'0.72rem', fontWeight:600, cursor:'pointer', border:`1px solid ${sidebarTab === 'projects' ? 'rgba(184,187,38,0.3)' : 'transparent'}`,
background: sidebarTab === 'projects' ? 'rgba(184,187,38,0.15)' : 'transparent',
color: sidebarTab === 'projects' ? '#b8bb26' : '#a89984',
borderRadius:4, whiteSpace: 'nowrap', transition:'all 0.2s ease', overflow:'hidden'
}} title={isSidebarCollapsed ? `Projects (${mode === 'dub' ? studioProjects.length : (mode === 'clone' ? profiles.filter(p => !p.instruct).length : profiles.filter(p => !!p.instruct).length)})` : undefined}><FolderOpen size={12} style={{verticalAlign:'middle', marginRight: isSidebarCollapsed ? 0 : 4}}/>
{!isSidebarCollapsed && `Projects (${mode === 'dub' ? studioProjects.length : (mode === 'clone' ? profiles.filter(p => !p.instruct).length : profiles.filter(p => !!p.instruct).length)})`}
flex:1, padding: isSidebarCollapsed ? '6px 0' : '4px 0', fontSize:'0.65rem', fontWeight:700, cursor:'pointer', borderLeft:'none', borderRight: isSidebarCollapsed ? 'none' : '1px solid rgba(255,255,255,0.06)', borderTop:'none',
borderBottom: sidebarTab === 'projects' ? '2px solid #b8bb26' : '2px solid transparent',
background: sidebarTab === 'projects' ? 'rgba(184,187,38,0.08)' : 'transparent',
color: sidebarTab === 'projects' ? '#b8bb26' : '#7c6f64',
borderRadius:0, whiteSpace:'nowrap', transition:'all 0.15s ease', overflow:'hidden', display:'flex', justifyContent:'center', alignItems:'center', gap:3, letterSpacing:'0.02em'
}} title="Projects"><FolderOpen size={11}/>
{!isSidebarCollapsed && <><strong>Projects</strong> <span style={{opacity:0.6, fontWeight:400}}>·</span> <span style={{fontWeight:400}}>{mode === 'dub' ? studioProjects.length : (mode === 'clone' ? profiles.filter(p => !p.instruct).length : profiles.filter(p => !!p.instruct).length)}</span></>}
</button>
<button onClick={() => setSidebarTab('history')} style={{
flex:1, padding: isSidebarCollapsed ? '4px 0' : '4px 6px', fontSize:'0.72rem', fontWeight:600, cursor:'pointer', border:`1px solid ${sidebarTab === 'history' ? 'rgba(211,134,155,0.3)' : 'transparent'}`,
background: sidebarTab === 'history' ? 'rgba(211,134,155,0.15)' : 'transparent',
color: sidebarTab === 'history' ? '#d3869b' : '#a89984',
borderRadius:4, whiteSpace: 'nowrap', transition:'all 0.2s ease', overflow:'hidden'
}} title={isSidebarCollapsed ? `History (${history.length + dubHistory.length})` : undefined}><History size={12} style={{verticalAlign:'middle', marginRight: isSidebarCollapsed ? 0 : 4}}/>
{!isSidebarCollapsed && `History (${history.length + dubHistory.length})`}
flex:1, padding: isSidebarCollapsed ? '6px 0' : '4px 0', fontSize:'0.65rem', fontWeight:700, cursor:'pointer', borderLeft:'none', borderRight: isSidebarCollapsed ? 'none' : '1px solid rgba(255,255,255,0.06)', borderTop:'none',
borderBottom: sidebarTab === 'history' ? '2px solid #d3869b' : '2px solid transparent',
background: sidebarTab === 'history' ? 'rgba(211,134,155,0.08)' : 'transparent',
color: sidebarTab === 'history' ? '#d3869b' : '#7c6f64',
borderRadius:0, whiteSpace:'nowrap', transition:'all 0.15s ease', overflow:'hidden', display:'flex', justifyContent:'center', alignItems:'center', gap:3, letterSpacing:'0.02em'
}} title="History"><History size={11}/>
{!isSidebarCollapsed && <><strong>History</strong> <span style={{opacity:0.6, fontWeight:400}}>·</span> <span style={{fontWeight:400}}>{history.length + dubHistory.length}</span></>}
</button>
<button onClick={() => setSidebarTab('downloads')} style={{
flex:1, padding: isSidebarCollapsed ? '6px 0' : '4px 0', fontSize:'0.65rem', fontWeight:700, cursor:'pointer', border:'none', borderTop:'none',
borderBottom: sidebarTab === 'downloads' ? '2px solid #8ec07c' : '2px solid transparent',
background: sidebarTab === 'downloads' ? 'rgba(142,192,124,0.08)' : 'transparent',
color: sidebarTab === 'downloads' ? '#8ec07c' : '#7c6f64',
borderRadius:0, whiteSpace:'nowrap', transition:'all 0.15s ease', overflow:'hidden', display:'flex', justifyContent:'center', alignItems:'center', gap:3, letterSpacing:'0.02em'
}} title="Downloads"><DownloadCloud size={11}/>
{!isSidebarCollapsed && <><strong>Exports</strong> <span style={{opacity:0.6, fontWeight:400}}>·</span> <span style={{fontWeight:400}}>{exportHistory.length}</span></>}
</button>
</div>
<div style={{flex:1, overflowY:'auto', padding:'8px', display: isSidebarCollapsed ? 'none' : 'block'}}>
<div style={{flex:1, overflowY:'auto', padding: isSidebarCollapsed ? '8px 4px' : '8px', display: 'flex', flexDirection: 'column', alignItems: isSidebarCollapsed ? 'center' : 'stretch', gap: isSidebarCollapsed ? 8 : 0}}>
{/* ── PROJECTS TAB ── */}
{sidebarTab === 'projects' && (
<>
{/* Save current work as dub project button (only in dub mode) */}
{mode === 'dub' && (dubStep !== 'idle' || dubVideoFile) && (
{mode === 'dub' && (dubStep !== 'idle' || dubVideoFile) && !isSidebarCollapsed && (
<button onClick={saveProject} style={{
width:'100%', marginBottom:10, padding:'7px 12px', display:'flex', alignItems:'center', justifyContent:'center', gap:6,
background: activeProjectId ? 'rgba(184,187,38,0.15)' : 'rgba(131,165,152,0.15)',
@@ -2070,16 +2147,22 @@ function App() {
<Save size={13}/> {activeProjectId ? 'Save Dub Project' : 'Save as New Dub Project'}
</button>
)}
{mode === 'dub' && (dubStep !== 'idle' || dubVideoFile) && isSidebarCollapsed && (
<button onClick={saveProject} title={activeProjectId ? 'Save Dub Project' : 'Save as New Dub Project'} style={{
width:'32px', height:'32px', padding:0, display:'flex', alignItems:'center', justifyContent:'center', marginBottom:8, flexShrink:0,
background: activeProjectId ? 'rgba(184,187,38,0.15)' : 'rgba(131,165,152,0.15)', border: `1px solid ${activeProjectId ? 'rgba(184,187,38,0.35)' : 'rgba(131,165,152,0.3)'}`,
borderRadius:6, cursor:'pointer', color: activeProjectId ? '#b8bb26' : '#83a598',
}}><Save size={14}/></button>
)}
<div
style={{fontSize:'0.68rem', color:'var(--text-secondary)', marginBottom:8, display:'flex', justifyContent:'space-between', alignItems:'center', cursor:'pointer', padding:'2px 0'}}
onClick={() => setIsSidebarProjectsCollapsed(!isSidebarProjectsCollapsed)}
>
<span>{mode === 'dub' ? 'Studio Projects (Dubbing)' : (mode === 'clone' ? 'Voice Clones (Audio)' : 'Designed Voices (Synthetic)')}</span>
{isSidebarProjectsCollapsed ? <ChevronDown size={12}/> : <ChevronUp size={12}/>}
</div>
{!isSidebarCollapsed && (
<div style={{fontSize:'0.68rem', color:'var(--text-secondary)', marginBottom:8, display:'flex', justifyContent:'space-between', alignItems:'center', cursor:'pointer', padding:'2px 0'}} onClick={() => setIsSidebarProjectsCollapsed(!isSidebarProjectsCollapsed)}>
<span>{mode === 'dub' ? 'Studio Projects (Dubbing)' : (mode === 'clone' ? 'Voice Clones (Audio)' : 'Designed Voices (Synthetic)')}</span>
{isSidebarProjectsCollapsed ? <ChevronDown size={12}/> : <ChevronUp size={12}/>}
</div>
)}
{!isSidebarProjectsCollapsed && (
{!isSidebarProjectsCollapsed && !isSidebarCollapsed && (
<>
{mode === 'dub' && (
<>
@@ -2168,13 +2251,34 @@ function App() {
)}
</>
)}
{isSidebarCollapsed && mode === 'dub' && studioProjects.map(proj => (
<div key={proj.id} title={`Load: ${proj.name}`} onClick={() => loadProject(proj.id)} style={{
width:'32px', height:'32px', flexShrink:0, display:'flex', justifyContent:'center', alignItems:'center', borderRadius:'6px', cursor:'pointer',
background: activeProjectId === proj.id ? 'rgba(184,187,38,0.2)' : 'rgba(255,255,255,0.05)', border:`1px solid ${activeProjectId === proj.id ? 'rgba(184,187,38,0.5)' : 'transparent'}`,
color: activeProjectId === proj.id ? '#b8bb26' : '#a89984'
}}>
<Film size={14}/>
</div>
))}
{isSidebarCollapsed && (mode === 'clone' || mode === 'design') && (mode === 'clone' ? profiles.filter(p => !p.instruct) : profiles.filter(p => !!p.instruct)).map(proj => (
<div key={proj.id} title={`Select: ${proj.name}`} onClick={() => handleSelectProfile(proj)} style={{
width:'32px', height:'32px', flexShrink:0, display:'flex', justifyContent:'center', alignItems:'center', borderRadius:'6px', cursor:'pointer', position:'relative',
background: selectedProfile === proj.id ? 'rgba(184,187,38,0.2)' : 'rgba(255,255,255,0.05)', border:`1px solid ${selectedProfile === proj.id ? 'rgba(184,187,38,0.5)' : 'transparent'}`,
color: selectedProfile === proj.id ? '#b8bb26' : '#a89984'
}}>
{mode === 'clone' ? <Fingerprint size={14}/> : <Wand2 size={14}/>}
{proj.is_locked && <Lock size={8} style={{position:'absolute', bottom:2, right:2, color:'#b8bb26'}}/>}
</div>
))}
</>
)}
{/* ── HISTORY TAB ── */}
{sidebarTab === 'history' && (
<>
<div style={{fontSize:'0.68rem', color:'var(--text-secondary)', marginBottom:8}}>Generation history · Stored in SQLite</div>
{!isSidebarCollapsed && <div style={{fontSize:'0.68rem', color:'var(--text-secondary)', marginBottom:8}}>Generation history · Stored in SQLite</div>}
{(history.length + dubHistory.length) === 0 ? (
<div style={{color:'var(--text-secondary)', textAlign:'center', padding:'24px 12px'}}>
<History size={28} style={{opacity:0.3, marginBottom:8}} />
@@ -2184,7 +2288,7 @@ function App() {
) : (
<>
{/* Dub history */}
{dubHistory.map(item => (
{!isSidebarCollapsed && dubHistory.map(item => (
<div key={`dub-${item.id}`} className="history-item">
<div className="history-header">
<div className="history-badge" style={{background:'rgba(131,165,152,0.15)', color:'#83a598'}}>
@@ -2216,7 +2320,7 @@ function App() {
))}
{/* Clone/Design history */}
{history.map(item => (
{!isSidebarCollapsed && history.map(item => (
<div key={item.id} className="history-item">
<div className="history-header">
<div className="history-badge">
@@ -2239,9 +2343,9 @@ function App() {
<Lock size={10}/> Lock
</button>
)}
<a href={`${API}/audio/${item.audio_path}`} download style={{padding:'4px 8px', background:'rgba(255,255,255,0.05)', border:'1px solid rgba(255,255,255,0.1)', color:'#ebdbb2', borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', gap:'4px', textDecoration:'none'}}>
<button onClick={(e) => handleNativeExport(e, item.audio_path, item.audio_path, item.mode)} style={{padding:'4px 8px', background:'rgba(255,255,255,0.05)', border:'1px solid rgba(255,255,255,0.1)', color:'#ebdbb2', borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', gap:'4px'}}>
<DownloadIcon size={10}/>
</a>
</button>
<button onClick={() => restoreHistory(item)} style={{padding:'4px 8px', background:'rgba(255,255,255,0.05)', border:'1px solid rgba(255,255,255,0.1)', color:'#ebdbb2', borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', gap:'4px'}}>
<FolderOpen size={10}/>
</button>
@@ -2254,9 +2358,26 @@ function App() {
))}
</>
)}
{isSidebarCollapsed && dubHistory.map(item => (
<div key={`dub-${item.id}`} title={`Dub: ${item.filename}`} onClick={() => restoreDubHistory(item)} style={{
width:'32px', height:'32px', flexShrink:0, display:'flex', justifyContent:'center', alignItems:'center', borderRadius:'6px', cursor:'pointer', background:'rgba(255,255,255,0.05)', border:'1px solid transparent', color:'#83a598'
}}>
<Film size={14}/>
</div>
))}
{(history.length + dubHistory.length) > 0 && (
{isSidebarCollapsed && history.map(item => (
<div key={item.id} title={`${item.mode||'history'}: ${item.text}`} onClick={() => restoreHistory(item)} style={{
width:'32px', height:'32px', flexShrink:0, display:'flex', justifyContent:'center', alignItems:'center', borderRadius:'6px', cursor:'pointer', background:'rgba(255,255,255,0.05)', border:'1px solid transparent', color: item.mode === 'clone' ? '#d3869b' : '#b8bb26'
}}>
{item.mode === 'clone' ? <Fingerprint size={14}/> : <Wand2 size={14}/>}
</div>
))}
{(history.length + dubHistory.length) > 0 && !isSidebarCollapsed && (
<button onClick={async () => { if (!confirm(`Clear all ${history.length + dubHistory.length} history items? This cannot be undone.`)) return; await fetch(`${API}/history`, {method:'DELETE'}); await fetch(`${API}/dub/history`, {method:'DELETE'}); await loadHistory(); await loadDubHistory(); toast.success('History cleared'); }}
style={{width:'100%', marginTop:10, padding:5, background:'transparent', border:'1px solid rgba(255,255,255,0.06)', borderRadius:6, color:'#665c54', cursor:'pointer', fontSize:'0.65rem', transition:'all 0.2s ease'}}
onMouseEnter={e => { e.target.style.borderColor = 'rgba(251,73,52,0.3)'; e.target.style.color = '#fb4934'; }}
onMouseLeave={e => { e.target.style.borderColor = 'rgba(255,255,255,0.06)'; e.target.style.color = '#665c54'; }}>
@@ -2265,6 +2386,64 @@ function App() {
)}
</>
)}
{/* ── DOWNLOADS TAB ── */}
{sidebarTab === 'downloads' && (
<>
{!isSidebarCollapsed && <div style={{fontSize:'0.68rem', color:'var(--text-secondary)', marginBottom:8}}>History of natively exported media</div>}
{exportHistory.length === 0 ? (
<div style={{color:'var(--text-secondary)', textAlign:'center', padding:'24px 12px'}}>
<DownloadCloud size={28} style={{opacity:0.3, marginBottom:8}} />
<p style={{fontSize:'0.78rem', margin:0, marginBottom:4}}>No downloaded outputs</p>
<p style={{fontSize:'0.62rem', margin:0, opacity:0.6}}>Export a file via Tauri to see it tracked here.</p>
</div>
) : (
<>
{!isSidebarCollapsed && exportHistory.map(item => (
<div key={item.id} className="history-item">
<div className="history-header">
<div className="history-badge" style={{background:'rgba(142,192,124,0.15)', color:'#8ec07c'}}>
<DownloadCloud size={10}/> {item.mode.toUpperCase()}
</div>
<div className="history-text" style={{margin:0, opacity:0.6}}>{new Date(item.created_at * 1000).toLocaleTimeString()}</div>
</div>
<div style={{fontSize:'0.72rem', color:'var(--text-primary)', marginTop:6, wordWrap:'break-word', fontWeight:600}}>
{item.filename}
</div>
<div style={{fontSize:'0.58rem', color:'#8ec07c', opacity:0.8, marginTop:4, wordWrap:'break-word', background:'rgba(0,0,0,0.15)', padding:'3px 5px', borderRadius:3, fontFamily:'monospace'}}>
{item.destination_path}
</div>
<div style={{display:'flex', gap:4, marginTop:6}}>
<button onClick={() => revealInFolder(item.destination_path)} style={{
flex:1, padding:'3px 6px', background:'rgba(142,192,124,0.1)', border:'1px solid rgba(142,192,124,0.25)',
color:'#8ec07c', borderRadius:4, fontSize:'0.62rem', cursor:'pointer', display:'flex', alignItems:'center', justifyContent:'center', gap:4, fontWeight:600, transition:'all 0.15s ease'
}}
onMouseEnter={e => { e.currentTarget.style.background='rgba(142,192,124,0.2)'; e.currentTarget.style.borderColor='rgba(142,192,124,0.4)'; }}
onMouseLeave={e => { e.currentTarget.style.background='rgba(142,192,124,0.1)'; e.currentTarget.style.borderColor='rgba(142,192,124,0.25)'; }}
>
<FolderOpen size={10}/> Open Folder
</button>
</div>
</div>
))}
{isSidebarCollapsed && exportHistory.map(item => (
<div key={item.id} title={`Exported: ${item.filename}\nTo: ${item.destination_path}\nClick to open folder`}
onClick={() => revealInFolder(item.destination_path)}
style={{
width:'32px', height:'32px', flexShrink:0, display:'flex', justifyContent:'center', alignItems:'center', borderRadius:'6px', cursor:'pointer', background:'rgba(255,255,255,0.05)', border:'1px solid transparent', color:'#8ec07c',
transition:'all 0.15s ease'
}}
onMouseEnter={e => { e.currentTarget.style.background='rgba(142,192,124,0.15)'; e.currentTarget.style.borderColor='rgba(142,192,124,0.3)'; }}
onMouseLeave={e => { e.currentTarget.style.background='rgba(255,255,255,0.05)'; e.currentTarget.style.borderColor='transparent'; }}
>
<FolderOpen size={14}/>
</div>
))}
</>
)}
</>
)}
</div>
</div>
}
+23 -15
View File
@@ -125,11 +125,12 @@ h1, h2, h3, h4 { font-family: 'Outfit', sans-serif; margin: 0; }
.header-area {
display: flex; align-items: center; gap: 8px; margin-bottom: 0;
flex-shrink: 0;
padding: 6px 12px 6px 80px; /* left padding for macOS traffic lights */
background: rgba(30, 30, 30, 0.65);
border-bottom: 1px solid var(--glass-border);
backdrop-filter: blur(12px) saturate(120%);
-webkit-backdrop-filter: blur(12px) saturate(120%);
padding: 12px 16px 12px 80px; /* Thicker header like macOS native */
background: rgba(15, 15, 15, 0.6);
border-bottom: 1px solid rgba(255,255,255,0.04);
box-shadow: 0 1px 12px rgba(0,0,0,0.2);
backdrop-filter: blur(24px) saturate(150%);
-webkit-backdrop-filter: blur(24px) saturate(150%);
user-select: none; /* prevent text selection when dragging */
}
.header-area h1 {
@@ -143,24 +144,31 @@ h1, h2, h3, h4 { font-family: 'Outfit', sans-serif; margin: 0; }
/* ═══ TABS ═══ */
.tabs {
display: flex;
background: rgba(0,0,0,0.3);
padding: 2px; border-radius: var(--radius); border: 1px solid rgba(255,255,255,0.05);
margin-bottom: 4px;
background: rgba(0,0,0,0.35);
padding: 3px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.03);
margin-bottom: 0;
box-shadow: inset 0 1px 4px rgba(0,0,0,0.4);
flex-shrink: 0;
}
.tab {
flex: 1; padding: 4px 6px; background: transparent; border: none; border-radius: 4px;
color: var(--text-secondary); font-weight: 600; font-size: 0.72rem; cursor: pointer;
display: flex; align-items: center; justify-content: center; gap: 4px;
transition: all var(--transition-smooth);
flex: 1; padding: 5px 12px; background: transparent; border: 1px solid transparent; border-radius: 6px;
color: #8a8a93; font-weight: 500; font-size: 0.74rem; cursor: pointer;
display: flex; align-items: center; justify-content: center; gap: 6px;
transition: all 0.2s cubic-bezier(0.25, 0.8, 0.25, 1);
position: relative;
text-shadow: 0 1px 1px rgba(0,0,0,0.5);
}
.tab:hover {
color: #e0e0e0;
}
.tab.active {
background: var(--glass-bg); color: white;
background: rgba(255,255,255,0.12); color: #ffffff;
border: 1px solid rgba(255,255,255,0.1);
box-shadow: 0 1px 4px rgba(0,0,0,0.3);
box-shadow: 0 2px 8px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.08);
font-weight: 600;
}
.tab:not(.active):hover { color: white; background: rgba(255,255,255,0.05); }
/* ═══ GRID SYSTEMS ═══ */
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
+7
View File
@@ -1,10 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
clearScreen: false,
resolve: {
preserveSymlinks: false,
alias: {
'@tauri-apps/plugin-dialog': path.resolve(__dirname, 'node_modules/@tauri-apps/plugin-dialog/dist-js/index.js'),
},
},
server: {
port: 5173,
strictPort: true,
+1
View File
@@ -43,6 +43,7 @@ dependencies = [
"psutil>=7.2.2",
"pyannote-audio>=4.0.4",
"pyinstaller>=6.19.0",
"imageio-ffmpeg>=0.6.0",
]
[project.optional-dependencies]
+13
View File
@@ -0,0 +1,13 @@
import sys, os
sys.path.insert(0, os.path.abspath("backend"))
import asyncio
from backend.main import _get_db, _dub_jobs, dub_transcribe, _find_ffmpeg
import httpx
print(f"FFmpeg path: {_find_ffmpeg()}")
try:
resp = httpx.post("http://localhost:8000/dub/transcribe/a2ea109c")
print(f"Status: {resp.status_code}")
print(resp.text)
except Exception as e:
print(f"Error calling local server: {e}")
+7
View File
@@ -0,0 +1,7 @@
import sys, types
sys.modules['torchcodec'] = types.ModuleType('torchcodec')
import torchaudio
from transformers import pipeline
print("Loading Whisper...")
pipe = pipeline("automatic-speech-recognition", model="openai/whisper-tiny")
print("Whisper loaded successfully!")
+8
View File
@@ -0,0 +1,8 @@
import torch
import torchaudio
from pyannote.audio import Pipeline
import numpy as np
import soundfile as sf
# Try to mock the pipeline
class Mock: pass
+17
View File
@@ -0,0 +1,17 @@
import sys, os
sys.path.insert(0, os.path.abspath("backend"))
from backend.main import _get_db, _dub_jobs, dub_transcribe, _find_ffmpeg
import asyncio
async def test():
print(f"FFmpeg path: {_find_ffmpeg()}")
try:
await dub_transcribe("312a7661")
except Exception as e:
import traceback
traceback.print_exc()
import warnings
warnings.filterwarnings("ignore")
asyncio.run(test())
+7
View File
@@ -0,0 +1,7 @@
import subprocess
import soundfile as sf
from transformers import pipeline
print("Loading pipeline...")
pipe = pipeline("automatic-speech-recognition", model="openai/whisper-tiny")
print("Pipeline loaded!")
Generated
+16
View File
@@ -1562,6 +1562,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "imageio-ffmpeg"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/44/bd/c3343c721f2a1b0c9fc71c1aebf1966a3b7f08c2eea8ed5437a2865611d6/imageio_ffmpeg-0.6.0.tar.gz", hash = "sha256:e2556bed8e005564a9f925bb7afa4002d82770d6b08825078b7697ab88ba1755", size = 25210, upload-time = "2025-01-16T21:34:32.747Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/58/87ef68ac83f4c7690961bce288fd8e382bc5f1513860fc7f90a9c1c1c6bf/imageio_ffmpeg-0.6.0-py3-none-macosx_10_9_intel.macosx_10_9_x86_64.whl", hash = "sha256:9d2baaf867088508d4a3458e61eeb30e945c4ad8016025545f66c4b5aaef0a61", size = 24932969, upload-time = "2025-01-16T21:34:20.464Z" },
{ url = "https://files.pythonhosted.org/packages/40/5c/f3d8a657d362cc93b81aab8feda487317da5b5d31c0e1fdfd5e986e55d17/imageio_ffmpeg-0.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b1ae3173414b5fc5f538a726c4e48ea97edc0d2cdc11f103afee655c463fa742", size = 21113891, upload-time = "2025-01-16T21:34:00.277Z" },
{ url = "https://files.pythonhosted.org/packages/33/e7/1925bfbc563c39c1d2e82501d8372734a5c725e53ac3b31b4c2d081e895b/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1d47bebd83d2c5fc770720d211855f208af8a596c82d17730aa51e815cdee6dc", size = 25632706, upload-time = "2025-01-16T21:33:53.475Z" },
{ url = "https://files.pythonhosted.org/packages/a0/2d/43c8522a2038e9d0e7dbdf3a61195ecc31ca576fb1527a528c877e87d973/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c7e46fcec401dd990405049d2e2f475e2b397779df2519b544b8aab515195282", size = 29498237, upload-time = "2025-01-16T21:34:13.726Z" },
{ url = "https://files.pythonhosted.org/packages/a0/13/59da54728351883c3c1d9fca1710ab8eee82c7beba585df8f25ca925f08f/imageio_ffmpeg-0.6.0-py3-none-win32.whl", hash = "sha256:196faa79366b4a82f95c0f4053191d2013f4714a715780f0ad2a68ff37483cc2", size = 19652251, upload-time = "2025-01-16T21:34:06.812Z" },
{ url = "https://files.pythonhosted.org/packages/2c/c6/fa760e12a2483469e2bf5058c5faff664acf66cadb4df2ad6205b016a73d/imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl", hash = "sha256:02fa47c83703c37df6bfe4896aab339013f62bf02c5ebf2dce6da56af04ffc0a", size = 31246824, upload-time = "2025-01-16T21:34:28.6Z" },
]
[[package]]
name = "importlib-metadata"
version = "8.7.1"
@@ -2696,6 +2710,7 @@ source = { editable = "." }
dependencies = [
{ name = "accelerate" },
{ name = "gradio" },
{ name = "imageio-ffmpeg" },
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "psutil" },
@@ -2748,6 +2763,7 @@ requires-dist = [
{ name = "gradio" },
{ name = "gradio", marker = "extra == 'ui'" },
{ name = "gradio-client", marker = "extra == 'ui'" },
{ name = "imageio-ffmpeg", specifier = ">=0.6.0" },
{ name = "jiwer", marker = "extra == 'eval'", specifier = "==3.1.0" },
{ name = "librosa", marker = "extra == 'eval'" },
{ name = "numpy" },