Add Tauri integration and server management features. Introduced auto-start functionality for the bundled server in Tauri environment, added configuration management for data directories, and refactored backend components to utilize the new config module. Updated dependencies and improved project structure for better organization.

This commit is contained in:
Jamie Pine
2026-01-25 04:25:45 -08:00
parent 6164877f7f
commit d14aca2267
19 changed files with 432 additions and 60 deletions
+1
View File
@@ -13,6 +13,7 @@
"check": "biome check --write src" "check": "biome check --write src"
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.0.0",
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "^18.3.0", "react-dom": "^18.3.0",
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
+51 -2
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { GenerationForm } from '@/components/Generation/GenerationForm'; import { GenerationForm } from '@/components/Generation/GenerationForm';
import { HistoryTable } from '@/components/History/HistoryTable'; import { HistoryTable } from '@/components/History/HistoryTable';
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm'; import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
@@ -7,14 +7,63 @@ import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
import { Toaster } from '@/components/ui/toaster'; import { Toaster } from '@/components/ui/toaster';
import { ProfileList } from '@/components/VoiceProfiles/ProfileList'; import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
import { Sidebar } from '@/components/Sidebar'; import { Sidebar } from '@/components/Sidebar';
import { isTauri, startServer, stopServer } from '@/lib/tauri';
// Track if server is starting to prevent duplicate starts
let serverStarting = false;
function App() { function App() {
const [activeTab, setActiveTab] = useState('profiles'); const [activeTab, setActiveTab] = useState('profiles');
const [serverReady, setServerReady] = useState(false);
// Auto-start server when running in Tauri
useEffect(() => {
if (!isTauri() || serverStarting) {
return;
}
serverStarting = true;
console.log('Running in Tauri, starting bundled server...');
startServer(false)
.then(() => {
console.log('Server is ready');
setServerReady(true);
})
.catch((error) => {
console.error('Failed to auto-start server:', error);
serverStarting = false;
});
// Cleanup: stop server on actual unmount (not StrictMode remount)
return () => {
// In production builds, we want to stop the server on unmount
// In dev mode, React StrictMode causes remounts, so we skip cleanup
if (import.meta.env?.PROD) {
stopServer().catch((error) => {
console.error('Failed to stop server on cleanup:', error);
});
serverStarting = false;
}
};
}, []);
// Show loading screen while server is starting in Tauri
if (isTauri() && !serverReady) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center space-y-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p className="text-muted-foreground">Starting server...</p>
</div>
</div>
);
}
return ( return (
<div className="min-h-screen bg-background flex"> <div className="min-h-screen bg-background flex">
<Sidebar activeTab={activeTab} onTabChange={setActiveTab} /> <Sidebar activeTab={activeTab} onTabChange={setActiveTab} />
<main className="flex-1 ml-20"> <main className="flex-1 ml-20">
<div className="container mx-auto px-8 py-8 max-w-7xl"> <div className="container mx-auto px-8 py-8 max-w-7xl">
{activeTab === 'profiles' && ( {activeTab === 'profiles' && (
@@ -14,13 +14,11 @@ import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import { useDeleteGeneration, useHistory } from '@/lib/hooks/useHistory'; import { useDeleteGeneration, useHistory } from '@/lib/hooks/useHistory';
import { formatDate, formatDuration } from '@/lib/utils/format'; import { formatDate, formatDuration } from '@/lib/utils/format';
import { useServerStore } from '@/stores/serverStore';
export function HistoryTable() { export function HistoryTable() {
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const limit = 20; const limit = 20;
const { toast } = useToast(); const { toast } = useToast();
const _serverUrl = useServerStore((state) => state.serverUrl);
const { data: historyData, isLoading } = useHistory({ const { data: historyData, isLoading } = useHistory({
limit, limit,
@@ -1,14 +1,13 @@
import { Mic, Plus } from 'lucide-react'; import { Mic, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { useDeleteProfile, useProfiles } from '@/lib/hooks/useProfiles'; import { useProfiles } from '@/lib/hooks/useProfiles';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
import { ProfileCard } from './ProfileCard'; import { ProfileCard } from './ProfileCard';
import { ProfileForm } from './ProfileForm'; import { ProfileForm } from './ProfileForm';
export function ProfileList() { export function ProfileList() {
const { data: profiles, isLoading, error } = useProfiles(); const { data: profiles, isLoading, error } = useProfiles();
const _deleteProfile = useDeleteProfile();
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen); const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
if (isLoading) { if (isLoading) {
+47
View File
@@ -0,0 +1,47 @@
/**
* Tauri integration utilities
*/
import { invoke } from '@tauri-apps/api/core';
/**
* Check if running in Tauri environment
*/
export function isTauri(): boolean {
return '__TAURI_INTERNALS__' in window;
}
/**
* Start the bundled Python server (Tauri only)
*/
export async function startServer(remote = false): Promise<string> {
if (!isTauri()) {
throw new Error('Not running in Tauri environment');
}
try {
const result = await invoke<string>('start_server', { remote });
console.log('Server started:', result);
return result;
} catch (error) {
console.error('Failed to start server:', error);
throw error;
}
}
/**
* Stop the bundled Python server (Tauri only)
*/
export async function stopServer(): Promise<void> {
if (!isTauri()) {
throw new Error('Not running in Tauri environment');
}
try {
await invoke('stop_server');
console.log('Server stopped');
} catch (error) {
console.error('Failed to stop server:', error);
throw error;
}
}
+29 -5
View File
@@ -3,7 +3,6 @@ PyInstaller build script for creating standalone Python server binary.
""" """
import PyInstaller.__main__ import PyInstaller.__main__
import sys
import os import os
from pathlib import Path from pathlib import Path
@@ -11,13 +10,30 @@ from pathlib import Path
def build_server(): def build_server():
"""Build Python server as standalone binary.""" """Build Python server as standalone binary."""
backend_dir = Path(__file__).parent backend_dir = Path(__file__).parent
# Find qwen_tts source directory (it's an editable install)
qwen_tts_path = Path('/Users/jamespine/Projects/voice/Qwen3-TTS')
# PyInstaller arguments # PyInstaller arguments
args = [ args = [
'main.py', 'server.py', # Use server.py as entry point instead of main.py
'--onefile', '--onefile',
'--name', 'voicebox-server', '--name', 'voicebox-server',
'--add-data', f'utils{os.pathsep}utils', # Include utils package '--paths', str(qwen_tts_path), # Add qwen_tts source to paths
'--hidden-import', 'backend',
'--hidden-import', 'backend.main',
'--hidden-import', 'backend.config',
'--hidden-import', 'backend.database',
'--hidden-import', 'backend.models',
'--hidden-import', 'backend.profiles',
'--hidden-import', 'backend.history',
'--hidden-import', 'backend.tts',
'--hidden-import', 'backend.transcribe',
'--hidden-import', 'backend.utils.audio',
'--hidden-import', 'backend.utils.cache',
'--hidden-import', 'backend.utils.progress',
'--hidden-import', 'backend.utils.hf_progress',
'--hidden-import', 'backend.utils.validation',
'--hidden-import', 'torch', '--hidden-import', 'torch',
'--hidden-import', 'transformers', '--hidden-import', 'transformers',
'--hidden-import', 'fastapi', '--hidden-import', 'fastapi',
@@ -25,7 +41,15 @@ def build_server():
'--hidden-import', 'sqlalchemy', '--hidden-import', 'sqlalchemy',
'--hidden-import', 'librosa', '--hidden-import', 'librosa',
'--hidden-import', 'soundfile', '--hidden-import', 'soundfile',
'--collect-all', 'qwen-tts', '--hidden-import', 'qwen_tts',
'--hidden-import', 'qwen_tts.inference',
'--hidden-import', 'qwen_tts.inference.qwen3_tts_model',
'--hidden-import', 'qwen_tts.inference.qwen3_tts_tokenizer',
'--hidden-import', 'qwen_tts.core',
'--hidden-import', 'qwen_tts.cli',
'--copy-metadata', 'qwen-tts',
'--collect-submodules', 'qwen_tts',
'--collect-data', 'qwen_tts',
'--noconfirm', '--noconfirm',
'--clean', '--clean',
] ]
+59
View File
@@ -0,0 +1,59 @@
"""
Configuration module for voicebox backend.
Handles data directory configuration for production bundling.
"""
from pathlib import Path
# Default data directory (used in development)
_data_dir = Path("data")
def set_data_dir(path: str | Path):
"""
Set the data directory path.
Args:
path: Path to the data directory
"""
global _data_dir
_data_dir = Path(path)
_data_dir.mkdir(parents=True, exist_ok=True)
print(f"Data directory set to: {_data_dir.absolute()}")
def get_data_dir() -> Path:
"""
Get the data directory path.
Returns:
Path to the data directory
"""
return _data_dir
def get_db_path() -> Path:
"""Get database file path."""
return _data_dir / "voicebox.db"
def get_profiles_dir() -> Path:
"""Get profiles directory path."""
path = _data_dir / "profiles"
path.mkdir(parents=True, exist_ok=True)
return path
def get_generations_dir() -> Path:
"""Get generations directory path."""
path = _data_dir / "generations"
path.mkdir(parents=True, exist_ok=True)
return path
def get_cache_dir() -> Path:
"""Get cache directory path."""
path = _data_dir / "cache"
path.mkdir(parents=True, exist_ok=True)
return path
def get_models_dir() -> Path:
"""Get models directory path."""
path = _data_dir / "models"
path.mkdir(parents=True, exist_ok=True)
return path
+17 -10
View File
@@ -9,6 +9,8 @@ from datetime import datetime
import uuid import uuid
from pathlib import Path from pathlib import Path
from . import config
Base = declarative_base() Base = declarative_base()
@@ -59,20 +61,25 @@ class Project(Base):
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Database setup # Database setup will be initialized in init_db()
_db_path = Path("data/voicebox.db") engine = None
_db_path.parent.mkdir(parents=True, exist_ok=True) SessionLocal = None
_db_path = None
engine = create_engine(
f"sqlite:///{_db_path}",
connect_args={"check_same_thread": False},
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def init_db(): def init_db():
"""Initialize database tables.""" """Initialize database tables."""
global engine, SessionLocal, _db_path
_db_path = config.get_db_path()
_db_path.parent.mkdir(parents=True, exist_ok=True)
engine = create_engine(
f"sqlite:///{_db_path}",
connect_args={"check_same_thread": False},
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
+4 -3
View File
@@ -12,11 +12,12 @@ from sqlalchemy import or_
from .models import GenerationRequest, GenerationResponse, HistoryQuery, HistoryResponse, HistoryListResponse from .models import GenerationRequest, GenerationResponse, HistoryQuery, HistoryResponse, HistoryListResponse
from .database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile from .database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile
from . import config
# Generations storage directory def _get_generations_dir() -> Path:
GENERATIONS_DIR = Path("data/generations") """Get generations directory from config."""
GENERATIONS_DIR.mkdir(parents=True, exist_ok=True) return config.get_generations_dir()
async def create_generation( async def create_generation(
+17 -7
View File
@@ -17,13 +17,10 @@ import tempfile
from pathlib import Path from pathlib import Path
import uuid import uuid
from . import database, models, profiles, history, tts, transcribe from . import database, models, profiles, history, tts, transcribe, config
from .database import get_db, init_db, Generation as DBGeneration, VoiceProfile as DBVoiceProfile from .database import get_db, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
from .utils.progress import get_progress_manager from .utils.progress import get_progress_manager
# Initialize database
init_db()
app = FastAPI( app = FastAPI(
title="voicebox API", title="voicebox API",
description="Production-quality Qwen3-TTS voice cloning API", description="Production-quality Qwen3-TTS voice cloning API",
@@ -269,7 +266,7 @@ async def generate_speech(
# Save audio # Save audio
generation_id = str(uuid.uuid4()) generation_id = str(uuid.uuid4())
audio_path = history.GENERATIONS_DIR / f"{generation_id}.wav" audio_path = config.get_generations_dir() / f"{generation_id}.wav"
from .utils.audio import save_audio from .utils.audio import save_audio
save_audio(audio, str(audio_path), sample_rate) save_audio(audio, str(audio_path), sample_rate)
@@ -740,10 +737,23 @@ if __name__ == "__main__":
default=8000, default=8000,
help="Port to bind to", help="Port to bind to",
) )
parser.add_argument(
"--data-dir",
type=str,
default=None,
help="Data directory for database, profiles, and generated audio",
)
args = parser.parse_args() args = parser.parse_args()
# Set data directory if provided
if args.data_dir:
config.set_data_dir(args.data_dir)
# Initialize database after data directory is set
database.init_db()
uvicorn.run( uvicorn.run(
"main:app", "backend.main:app",
host=args.host, host=args.host,
port=args.port, port=args.port,
reload=False, # Disable reload in production reload=False, # Disable reload in production
+7 -6
View File
@@ -22,11 +22,12 @@ from .database import (
) )
from .utils.audio import validate_reference_audio, load_audio, save_audio from .utils.audio import validate_reference_audio, load_audio, save_audio
from .tts import get_tts_model from .tts import get_tts_model
from . import config
# Profile storage directory def _get_profiles_dir() -> Path:
PROFILES_DIR = Path("data/profiles") """Get profiles directory from config."""
PROFILES_DIR.mkdir(parents=True, exist_ok=True) return config.get_profiles_dir()
async def create_profile( async def create_profile(
@@ -58,7 +59,7 @@ async def create_profile(
db.refresh(db_profile) db.refresh(db_profile)
# Create profile directory # Create profile directory
profile_dir = PROFILES_DIR / db_profile.id profile_dir = _get_profiles_dir() / db_profile.id
profile_dir.mkdir(parents=True, exist_ok=True) profile_dir.mkdir(parents=True, exist_ok=True)
return VoiceProfileResponse.model_validate(db_profile) return VoiceProfileResponse.model_validate(db_profile)
@@ -94,7 +95,7 @@ async def add_profile_sample(
# Create sample ID and directory # Create sample ID and directory
sample_id = str(uuid.uuid4()) sample_id = str(uuid.uuid4())
profile_dir = PROFILES_DIR / profile_id profile_dir = _get_profiles_dir() / profile_id
profile_dir.mkdir(parents=True, exist_ok=True) profile_dir.mkdir(parents=True, exist_ok=True)
# Copy audio file to profile directory # Copy audio file to profile directory
@@ -235,7 +236,7 @@ async def delete_profile(
db.commit() db.commit()
# Delete profile directory # Delete profile directory
profile_dir = PROFILES_DIR / profile_id profile_dir = _get_profiles_dir() / profile_id
if profile_dir.exists(): if profile_dir.exists():
shutil.rmtree(profile_dir) shutil.rmtree(profile_dir)
+49
View File
@@ -0,0 +1,49 @@
"""
Entry point for PyInstaller-bundled voicebox server.
This module provides an entry point that works with PyInstaller by using
absolute imports instead of relative imports.
"""
import argparse
import uvicorn
# Import the FastAPI app from the backend package
from backend.main import app
from backend import config, database
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="voicebox backend server")
parser.add_argument(
"--host",
type=str,
default="127.0.0.1",
help="Host to bind to (use 0.0.0.0 for remote access)",
)
parser.add_argument(
"--port",
type=int,
default=8000,
help="Port to bind to",
)
parser.add_argument(
"--data-dir",
type=str,
default=None,
help="Data directory for database, profiles, and generated audio",
)
args = parser.parse_args()
# Set data directory if provided
if args.data_dir:
config.set_data_dir(args.data_dir)
# Initialize database after data directory is set
database.init_db()
uvicorn.run(
app,
host=args.host,
port=args.port,
log_level="info",
)
+2 -1
View File
@@ -13,6 +13,7 @@ from .utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_pro
from .utils.audio import normalize_audio from .utils.audio import normalize_audio
from .utils.progress import get_progress_manager from .utils.progress import get_progress_manager
from .utils.hf_progress import HFProgressTracker, create_hf_progress_callback from .utils.hf_progress import HFProgressTracker, create_hf_progress_callback
from . import config
class TTSModel: class TTSModel:
@@ -63,7 +64,7 @@ class TTSModel:
raise ValueError(f"Unknown model size: {model_size}") raise ValueError(f"Unknown model size: {model_size}")
# Check if model exists locally (backwards compatibility) # Check if model exists locally (backwards compatibility)
local_path = Path("data/models") / local_model_map[model_size] local_path = config.get_models_dir() / local_model_map[model_size]
if local_path.exists(): if local_path.exists():
print(f"Found local model at {local_path}") print(f"Found local model at {local_path}")
return str(local_path) return str(local_path)
+19 -16
View File
@@ -5,12 +5,15 @@ Voice prompt caching utilities.
import hashlib import hashlib
import torch import torch
from pathlib import Path from pathlib import Path
from typing import Optional, Tuple from typing import Optional
import soundfile as sf
from .. import config
_cache_dir = Path("data/cache") def _get_cache_dir() -> Path:
_cache_dir.mkdir(parents=True, exist_ok=True) """Get cache directory from config."""
return config.get_cache_dir()
# In-memory cache # In-memory cache
_memory_cache: dict[str, torch.Tensor] = {} _memory_cache: dict[str, torch.Tensor] = {}
@@ -19,21 +22,21 @@ _memory_cache: dict[str, torch.Tensor] = {}
def get_cache_key(audio_path: str, reference_text: str) -> str: def get_cache_key(audio_path: str, reference_text: str) -> str:
""" """
Generate cache key from audio file and reference text. Generate cache key from audio file and reference text.
Args: Args:
audio_path: Path to audio file audio_path: Path to audio file
reference_text: Reference text reference_text: Reference text
Returns: Returns:
Cache key (MD5 hash) Cache key (MD5 hash)
""" """
# Read audio file # Read audio file
with open(audio_path, "rb") as f: with open(audio_path, "rb") as f:
audio_bytes = f.read() audio_bytes = f.read()
# Combine audio bytes and text # Combine audio bytes and text
combined = audio_bytes + reference_text.encode("utf-8") combined = audio_bytes + reference_text.encode("utf-8")
# Generate hash # Generate hash
return hashlib.md5(combined).hexdigest() return hashlib.md5(combined).hexdigest()
@@ -43,19 +46,19 @@ def get_cached_voice_prompt(
) -> Optional[torch.Tensor]: ) -> Optional[torch.Tensor]:
""" """
Get cached voice prompt if available. Get cached voice prompt if available.
Args: Args:
cache_key: Cache key cache_key: Cache key
Returns: Returns:
Cached voice prompt tensor or None Cached voice prompt tensor or None
""" """
# Check in-memory cache # Check in-memory cache
if cache_key in _memory_cache: if cache_key in _memory_cache:
return _memory_cache[cache_key] return _memory_cache[cache_key]
# Check disk cache # Check disk cache
cache_file = _cache_dir / f"{cache_key}.prompt" cache_file = _get_cache_dir() / f"{cache_key}.prompt"
if cache_file.exists(): if cache_file.exists():
try: try:
prompt = torch.load(cache_file) prompt = torch.load(cache_file)
@@ -64,7 +67,7 @@ def get_cached_voice_prompt(
except Exception: except Exception:
# Cache file corrupted, delete it # Cache file corrupted, delete it
cache_file.unlink() cache_file.unlink()
return None return None
@@ -74,14 +77,14 @@ def cache_voice_prompt(
) -> None: ) -> None:
""" """
Cache voice prompt to memory and disk. Cache voice prompt to memory and disk.
Args: Args:
cache_key: Cache key cache_key: Cache key
voice_prompt: Voice prompt tensor voice_prompt: Voice prompt tensor
""" """
# Store in memory # Store in memory
_memory_cache[cache_key] = voice_prompt _memory_cache[cache_key] = voice_prompt
# Store on disk # Store on disk
cache_file = _cache_dir / f"{cache_key}.prompt" cache_file = _get_cache_dir() / f"{cache_key}.prompt"
torch.save(voice_prompt, cache_file) torch.save(voice_prompt, cache_file)
+47
View File
@@ -0,0 +1,47 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_data_files
from PyInstaller.utils.hooks import collect_submodules
from PyInstaller.utils.hooks import copy_metadata
datas = []
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.utils.validation', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli']
datas += collect_data_files('qwen_tts')
datas += copy_metadata('qwen-tts')
hiddenimports += collect_submodules('qwen_tts')
a = Analysis(
['server.py'],
pathex=['/Users/jamespine/Projects/voice/Qwen3-TTS'],
binaries=[],
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='voicebox-server',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
+1
View File
@@ -31,6 +31,7 @@
"@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-toast": "^1.2.1",
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
"@tanstack/react-query-devtools": "^5.0.0", "@tanstack/react-query-devtools": "^5.0.0",
"@tauri-apps/api": "^2.0.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
+77 -6
View File
@@ -15,11 +15,34 @@ async fn start_server(
state: State<'_, ServerState>, state: State<'_, ServerState>,
remote: Option<bool>, remote: Option<bool>,
) -> Result<String, String> { ) -> Result<String, String> {
// Check if server is already running
if state.child.lock().unwrap().is_some() {
return Ok("Server already running on http://localhost:8000".to_string());
}
// Get app data directory
let data_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
// Ensure data directory exists
std::fs::create_dir_all(&data_dir)
.map_err(|e| format!("Failed to create data dir: {}", e))?;
let mut sidecar = app let mut sidecar = app
.shell() .shell()
.sidecar("voicebox-server") .sidecar("voicebox-server")
.map_err(|e| format!("Failed to get sidecar: {}", e))?; .map_err(|e| format!("Failed to get sidecar: {}", e))?;
// Pass data directory to Python server
sidecar = sidecar.args([
"--data-dir",
data_dir
.to_str()
.ok_or_else(|| "Invalid data dir path".to_string())?,
]);
if remote.unwrap_or(false) { if remote.unwrap_or(false) {
sidecar = sidecar.args(["--host", "0.0.0.0"]); sidecar = sidecar.args(["--host", "0.0.0.0"]);
} }
@@ -31,13 +54,61 @@ async fn start_server(
// Store child process // Store child process
*state.child.lock().unwrap() = Some(child); *state.child.lock().unwrap() = Some(child);
// Wait for server to be ready (listen for startup log) // Wait for server to be ready by listening for startup log
let timeout = tokio::time::Duration::from_secs(30);
let start_time = tokio::time::Instant::now();
loop {
if start_time.elapsed() > timeout {
return Err("Server startup timeout".to_string());
}
match tokio::time::timeout(tokio::time::Duration::from_millis(100), rx.recv()).await {
Ok(Some(event)) => {
match event {
tauri_plugin_shell::process::CommandEvent::Stdout(line) => {
let line_str = String::from_utf8_lossy(&line);
println!("Server output: {}", line_str);
if line_str.contains("Uvicorn running") || line_str.contains("Application startup complete") {
println!("Server is ready!");
break;
}
}
tauri_plugin_shell::process::CommandEvent::Stderr(line) => {
let line_str = String::from_utf8_lossy(&line);
eprintln!("Server: {}", line_str);
// Uvicorn logs to stderr, so check there too
if line_str.contains("Uvicorn running") || line_str.contains("Application startup complete") {
println!("Server is ready!");
break;
}
}
_ => {}
}
}
Ok(None) => {
return Err("Server process ended unexpectedly".to_string());
}
Err(_) => {
// Timeout on this recv, continue loop
continue;
}
}
}
// Spawn task to continue reading output
tokio::spawn(async move { tokio::spawn(async move {
while let Some(event) = rx.recv().await { while let Some(event) = rx.recv().await {
if let tauri_plugin_shell::process::CommandEvent::Stdout(line) = event { match event {
if String::from_utf8_lossy(&line).contains("Uvicorn running") { tauri_plugin_shell::process::CommandEvent::Stdout(line) => {
break; println!("Server: {}", String::from_utf8_lossy(&line));
} }
tauri_plugin_shell::process::CommandEvent::Stderr(line) => {
eprintln!("Server error: {}", String::from_utf8_lossy(&line));
}
_ => {}
} }
} }
}); });
@@ -61,11 +132,11 @@ pub fn run() {
child: Mutex::new(None), child: Mutex::new(None),
}) })
.invoke_handler(tauri::generate_handler![start_server, stop_server]) .invoke_handler(tauri::generate_handler![start_server, stop_server])
.setup(|app| { .setup(|_app| {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
{ {
// Get all windows and open devtools on the first one // Get all windows and open devtools on the first one
if let Some((_, window)) = app.webview_windows().iter().next() { if let Some((_, window)) = _app.webview_windows().iter().next() {
window.open_devtools(); window.open_devtools();
println!("Dev tools opened"); println!("Dev tools opened");
} else { } else {
+3
View File
@@ -12,6 +12,9 @@
"bundle": { "bundle": {
"active": true, "active": true,
"targets": "all", "targets": "all",
"externalBin": [
"binaries/voicebox-server"
],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
+1
View File
@@ -15,6 +15,7 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"types": ["vite/client"],
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["../app/src/*"] "@/*": ["../app/src/*"]