mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-29 15:15:27 -07:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60aac279ce | ||
|
|
8b1c7552be | ||
|
|
eb5869e59f |
@@ -4,6 +4,8 @@ import voiceboxLogo from '@/assets/voicebox-logo.png';
|
|||||||
import ShinyText from '@/components/ShinyText';
|
import ShinyText from '@/components/ShinyText';
|
||||||
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
||||||
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { HealthResponse } from '@/lib/api/types';
|
||||||
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
@@ -11,6 +13,33 @@ import { router } from '@/router';
|
|||||||
import { useLogStore } from '@/stores/logStore';
|
import { useLogStore } from '@/stores/logStore';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a health response has the expected Voicebox-specific shape.
|
||||||
|
* Prevents misidentifying an unrelated service on the same port.
|
||||||
|
*/
|
||||||
|
function isVoiceboxHealthResponse(health: HealthResponse): boolean {
|
||||||
|
return (
|
||||||
|
health?.status === 'healthy' &&
|
||||||
|
typeof health.model_loaded === 'boolean' &&
|
||||||
|
typeof health.gpu_available === 'boolean'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a startup error indicates the port is occupied by an external
|
||||||
|
* server (which we should try to reuse via health-check polling) vs. a real
|
||||||
|
* failure (missing sidecar, signing issue, etc.) that should surface immediately.
|
||||||
|
*/
|
||||||
|
function isPortInUseError(error: unknown): boolean {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
return (
|
||||||
|
msg.includes('already in use') ||
|
||||||
|
msg.includes('port') ||
|
||||||
|
msg.includes('EADDRINUSE') ||
|
||||||
|
msg.includes('address already in use')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const LOADING_MESSAGES = [
|
const LOADING_MESSAGES = [
|
||||||
'Warming up tensors...',
|
'Warming up tensors...',
|
||||||
'Calibrating synthesizer engine...',
|
'Calibrating synthesizer engine...',
|
||||||
@@ -37,6 +66,7 @@ const LOADING_MESSAGES = [
|
|||||||
function App() {
|
function App() {
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
const [serverReady, setServerReady] = useState(false);
|
const [serverReady, setServerReady] = useState(false);
|
||||||
|
const [startupError, setStartupError] = useState<string | null>(null);
|
||||||
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
|
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
|
||||||
const serverStartingRef = useRef(false);
|
const serverStartingRef = useRef(false);
|
||||||
|
|
||||||
@@ -122,6 +152,46 @@ function App() {
|
|||||||
serverStartingRef.current = false;
|
serverStartingRef.current = false;
|
||||||
// @ts-expect-error - adding property to window
|
// @ts-expect-error - adding property to window
|
||||||
window.__voiceboxServerStartedByApp = false;
|
window.__voiceboxServerStartedByApp = false;
|
||||||
|
|
||||||
|
// Only fall back to health-check polling when the error indicates the
|
||||||
|
// port is occupied (likely an external server). For real failures
|
||||||
|
// (missing sidecar, signing issues, etc.) surface the error immediately.
|
||||||
|
if (!isPortInUseError(error)) {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('Real startup failure — not polling:', msg);
|
||||||
|
setStartupError(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to polling: the server may already be running externally
|
||||||
|
// (e.g. started via python/uvicorn/Docker). Poll the health endpoint
|
||||||
|
// until it responds with a valid Voicebox payload, then transition to
|
||||||
|
// the main UI.
|
||||||
|
console.log('Falling back to health-check polling...');
|
||||||
|
const pollInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const health = await apiClient.getHealth();
|
||||||
|
if (!isVoiceboxHealthResponse(health)) {
|
||||||
|
console.log('Health response is not from a Voicebox server, keep polling...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('External Voicebox server detected via health check');
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
setServerReady(true);
|
||||||
|
} catch {
|
||||||
|
// Server not ready yet, keep polling
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
// Stop polling after 2 minutes and surface the failure
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
serverStartingRef.current = false;
|
||||||
|
setStartupError(
|
||||||
|
'Could not connect to a Voicebox server within 2 minutes. ' +
|
||||||
|
'Please check that the server is running and try again.',
|
||||||
|
);
|
||||||
|
}, 120_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup: stop server on actual unmount (not StrictMode remount)
|
// Cleanup: stop server on actual unmount (not StrictMode remount)
|
||||||
@@ -168,6 +238,24 @@ function App() {
|
|||||||
className="w-48 h-48 object-contain animate-fade-in-scale relative z-10"
|
className="w-48 h-48 object-contain animate-fade-in-scale relative z-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{startupError ? (
|
||||||
|
<div className="animate-fade-in-delayed max-w-md mx-auto space-y-3">
|
||||||
|
<p className="text-lg font-medium text-destructive">Server startup failed</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{startupError}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-2 px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setStartupError(null);
|
||||||
|
serverStartingRef.current = false;
|
||||||
|
// Trigger a re-mount of the effect by toggling state
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="animate-fade-in-delayed">
|
<div className="animate-fade-in-delayed">
|
||||||
<ShinyText
|
<ShinyText
|
||||||
text={LOADING_MESSAGES[loadingMessageIndex]}
|
text={LOADING_MESSAGES[loadingMessageIndex]}
|
||||||
@@ -177,6 +265,7 @@ function App() {
|
|||||||
shineColor="hsl(var(--foreground))"
|
shineColor="hsl(var(--foreground))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { QueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared QueryClient instance used across the app.
|
||||||
|
*
|
||||||
|
* Extracted into its own side-effect-free module so it can be imported from
|
||||||
|
* both the React bootstrap (main.tsx) and non-React code (stores, utilities)
|
||||||
|
* without pulling in ReactDOM or other bootstrap side effects.
|
||||||
|
*/
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime)
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
+2
-12
@@ -1,20 +1,10 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
// import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
// import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
import { queryClient } from './lib/queryClient';
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
|
||||||
gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime)
|
|
||||||
retry: 1,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
|
import { queryClient } from '@/lib/queryClient';
|
||||||
|
|
||||||
interface ServerStore {
|
interface ServerStore {
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
@@ -30,11 +31,25 @@ interface ServerStore {
|
|||||||
setCustomModelsDir: (dir: string | null) => void;
|
setCustomModelsDir: (dir: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate all React Query caches so stale data from the previous
|
||||||
|
* server is not shown. Called when the server URL changes.
|
||||||
|
*/
|
||||||
|
function invalidateAllServerData() {
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
}
|
||||||
|
|
||||||
export const useServerStore = create<ServerStore>()(
|
export const useServerStore = create<ServerStore>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set, get) => ({
|
||||||
serverUrl: 'http://127.0.0.1:17493',
|
serverUrl: 'http://127.0.0.1:17493',
|
||||||
setServerUrl: (url) => set({ serverUrl: url }),
|
setServerUrl: (url) => {
|
||||||
|
const prev = get().serverUrl;
|
||||||
|
set({ serverUrl: url });
|
||||||
|
if (url !== prev) {
|
||||||
|
invalidateAllServerData();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
setIsConnected: (connected) => set({ isConnected: connected }),
|
setIsConnected: (connected) => set({ isConnected: connected }),
|
||||||
|
|||||||
@@ -53,6 +53,41 @@ fn find_voicebox_pid_on_port(port: u16) -> Option<u32> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if a Voicebox server is responding on the given port.
|
||||||
|
///
|
||||||
|
/// Sends an HTTP GET to `/health` and returns `true` only if the response
|
||||||
|
/// is valid JSON matching the Voicebox `HealthResponse` schema — specifically
|
||||||
|
/// `status` must be `"healthy"`, and both `model_loaded` and `gpu_available`
|
||||||
|
/// must be present as booleans. This prevents misidentifying an unrelated
|
||||||
|
/// service that happens to expose a `/health` endpoint.
|
||||||
|
#[allow(dead_code)] // Used in platform-specific cfg blocks
|
||||||
|
fn check_health(port: u16) -> bool {
|
||||||
|
let url = format!("http://127.0.0.1:{}/health", port);
|
||||||
|
match reqwest::blocking::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(3))
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(client) => match client.get(&url).send() {
|
||||||
|
Ok(resp) => {
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Parse as JSON and validate Voicebox-specific fields
|
||||||
|
match resp.json::<serde_json::Value>() {
|
||||||
|
Ok(body) => {
|
||||||
|
body.get("status").and_then(|v| v.as_str()) == Some("healthy")
|
||||||
|
&& body.get("model_loaded").map(|v| v.is_boolean()).unwrap_or(false)
|
||||||
|
&& body.get("gpu_available").map(|v| v.is_boolean()).unwrap_or(false)
|
||||||
|
}
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => false,
|
||||||
|
},
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ServerState {
|
struct ServerState {
|
||||||
child: Mutex<Option<tauri_plugin_shell::process::CommandChild>>,
|
child: Mutex<Option<tauri_plugin_shell::process::CommandChild>>,
|
||||||
server_pid: Mutex<Option<u32>>,
|
server_pid: Mutex<Option<u32>>,
|
||||||
@@ -80,7 +115,8 @@ async fn start_server(
|
|||||||
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
|
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if a voicebox server is already running on our port (from previous session with keep_running=true)
|
// Check if a voicebox server is already running on our port (from previous session with keep_running=true,
|
||||||
|
// or an externally started server e.g. via `python`, `uvicorn`, Docker, etc.)
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
@@ -101,6 +137,20 @@ async fn start_server(
|
|||||||
*state.server_pid.lock().unwrap() = Some(pid);
|
*state.server_pid.lock().unwrap() = Some(pid);
|
||||||
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
|
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Process name doesn't contain "voicebox" — could be an external
|
||||||
|
// Python/uvicorn/Docker server. Verify via HTTP health check.
|
||||||
|
println!("Port {} in use by '{}' (PID: {}), checking if it's a Voicebox server...", SERVER_PORT, command, pid_str);
|
||||||
|
if check_health(SERVER_PORT) {
|
||||||
|
println!("Health check passed — reusing external server on port {}", SERVER_PORT);
|
||||||
|
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
|
||||||
|
}
|
||||||
|
println!("Health check failed — port is occupied by a non-Voicebox process");
|
||||||
|
return Err(format!(
|
||||||
|
"Port {} is already in use by another application ({}). \
|
||||||
|
Close it or change the Voicebox server port.",
|
||||||
|
SERVER_PORT, command
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,12 +164,19 @@ async fn start_server(
|
|||||||
&format!("127.0.0.1:{}", SERVER_PORT).parse().unwrap(),
|
&format!("127.0.0.1:{}", SERVER_PORT).parse().unwrap(),
|
||||||
std::time::Duration::from_secs(1),
|
std::time::Duration::from_secs(1),
|
||||||
).is_ok() {
|
).is_ok() {
|
||||||
// Port is in use — check if it's a voicebox process
|
// Port is in use — check if it's a voicebox process by name first
|
||||||
if let Some(pid) = find_voicebox_pid_on_port(SERVER_PORT) {
|
if let Some(pid) = find_voicebox_pid_on_port(SERVER_PORT) {
|
||||||
println!("Found existing voicebox-server on port {} (PID: {}), reusing it", SERVER_PORT, pid);
|
println!("Found existing voicebox-server on port {} (PID: {}), reusing it", SERVER_PORT, pid);
|
||||||
*state.server_pid.lock().unwrap() = Some(pid);
|
*state.server_pid.lock().unwrap() = Some(pid);
|
||||||
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
|
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
|
||||||
} else {
|
}
|
||||||
|
// Process name doesn't match — could be an external Python/Docker server.
|
||||||
|
// Verify via HTTP health check before giving up.
|
||||||
|
println!("Port {} in use by unknown process, checking if it's a Voicebox server...", SERVER_PORT);
|
||||||
|
if check_health(SERVER_PORT) {
|
||||||
|
println!("Health check passed — reusing external server on port {}", SERVER_PORT);
|
||||||
|
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
|
||||||
|
}
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Port {} is already in use by another application. \
|
"Port {} is already in use by another application. \
|
||||||
Close the other application or change the Voicebox port.",
|
Close the other application or change the Voicebox port.",
|
||||||
@@ -127,7 +184,6 @@ async fn start_server(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Kill any orphaned voicebox-server from previous session on legacy port 8000
|
// Kill any orphaned voicebox-server from previous session on legacy port 8000
|
||||||
// This handles upgrades from older versions that used a fixed port
|
// This handles upgrades from older versions that used a fixed port
|
||||||
|
|||||||
Reference in New Issue
Block a user