mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-29 07:05:14 -07:00
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:
@@ -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",
|
||||||
|
|||||||
+50
-1
@@ -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,9 +7,58 @@ 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">
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-4
@@ -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
|
||||||
|
|
||||||
@@ -12,12 +11,29 @@ 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',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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] = {}
|
||||||
@@ -55,7 +58,7 @@ def get_cached_voice_prompt(
|
|||||||
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)
|
||||||
@@ -83,5 +86,5 @@ def cache_voice_prompt(
|
|||||||
_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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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/*"]
|
||||||
|
|||||||
Reference in New Issue
Block a user