mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-26 21:55:15 -07:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5f4606a6c | ||
|
|
146ef5aaeb | ||
|
|
971604d14f | ||
|
|
953e6ec7d8 | ||
|
|
d3c65fc6c2 | ||
|
|
b6e772c6ac | ||
|
|
a6b070201b | ||
|
|
30352e2419 | ||
|
|
bfa38b36b7 | ||
|
|
1b66a528d1 | ||
|
|
bef4092e6e | ||
|
|
9654f7b642 |
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 0.1.10
|
current_version = 0.1.11
|
||||||
commit = True
|
commit = True
|
||||||
tag = True
|
tag = True
|
||||||
tag_name = v{new_version}
|
tag_name = v{new_version}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ Unlike cloud services that lock your voice data behind subscriptions, Voicebox g
|
|||||||
- **Model flexibility** — currently powered by Qwen3-TTS, with support for XTTS, Bark, and other models coming soon
|
- **Model flexibility** — currently powered by Qwen3-TTS, with support for XTTS, Bark, and other models coming soon
|
||||||
- **API-first** — use the desktop app or integrate voice synthesis into your own projects
|
- **API-first** — use the desktop app or integrate voice synthesis into your own projects
|
||||||
- **Native performance** — built with Tauri (Rust), not Electron
|
- **Native performance** — built with Tauri (Rust), not Electron
|
||||||
|
- **Super fast on Mac** — MLX backend with native Metal acceleration for 4-5x faster inference on Apple Silicon
|
||||||
|
|
||||||
Download a voice model, clone any voice from a few seconds of audio, and compose multi-voice projects with studio-grade editing tools. No Python install required, no cloud dependency, no limits.
|
Download a voice model, clone any voice from a few seconds of audio, and compose multi-voice projects with studio-grade editing tools. No Python install required, no cloud dependency, no limits.
|
||||||
|
|
||||||
@@ -97,6 +98,7 @@ Powered by Alibaba's **Qwen3-TTS** — a breakthrough model that achieves near-p
|
|||||||
- **Instant cloning** — Upload a sample, get a voice profile
|
- **Instant cloning** — Upload a sample, get a voice profile
|
||||||
- **High fidelity** — Natural prosody, emotion, and cadence
|
- **High fidelity** — Natural prosody, emotion, and cadence
|
||||||
- **Multi-language** — English, Chinese, and more coming
|
- **Multi-language** — English, Chinese, and more coming
|
||||||
|
- **Lightning fast on Mac** — MLX backend leverages Apple Silicon's Neural Engine for super fast generation
|
||||||
|
|
||||||
### Voice Profile Management
|
### Voice Profile Management
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/app",
|
"name": "@voicebox/app",
|
||||||
"version": "0.1.10",
|
"version": "0.1.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+23
-16
@@ -4,15 +4,10 @@ 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 { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import {
|
|
||||||
isTauri,
|
|
||||||
setKeepServerRunning,
|
|
||||||
setupWindowCloseHandler,
|
|
||||||
startServer,
|
|
||||||
} from '@/lib/tauri';
|
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { router } from '@/router';
|
import { router } from '@/router';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
|
||||||
const LOADING_MESSAGES = [
|
const LOADING_MESSAGES = [
|
||||||
'Warming up tensors...',
|
'Warming up tensors...',
|
||||||
@@ -38,29 +33,38 @@ const LOADING_MESSAGES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const platform = usePlatform();
|
||||||
const [serverReady, setServerReady] = useState(false);
|
const [serverReady, setServerReady] = useState(false);
|
||||||
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
|
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
|
||||||
const serverStartingRef = useRef(false);
|
const serverStartingRef = useRef(false);
|
||||||
|
|
||||||
// Sync stored setting to Rust on startup
|
// Sync stored setting to Rust on startup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isTauri()) {
|
if (platform.metadata.isTauri) {
|
||||||
const keepRunning = useServerStore.getState().keepServerRunningOnClose;
|
const keepRunning = useServerStore.getState().keepServerRunningOnClose;
|
||||||
setKeepServerRunning(keepRunning).catch((error) => {
|
platform.lifecycle.setKeepServerRunning(keepRunning).catch((error) => {
|
||||||
console.error('Failed to sync initial setting to Rust:', error);
|
console.error('Failed to sync initial setting to Rust:', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
}, [platform]);
|
||||||
|
|
||||||
|
// Setup lifecycle callbacks
|
||||||
|
useEffect(() => {
|
||||||
|
platform.lifecycle.onServerReady = () => {
|
||||||
|
setServerReady(true);
|
||||||
|
};
|
||||||
|
}, [platform]);
|
||||||
|
|
||||||
// Setup window close handler and auto-start server when running in Tauri (production only)
|
// Setup window close handler and auto-start server when running in Tauri (production only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTauri()) {
|
if (!platform.metadata.isTauri) {
|
||||||
|
setServerReady(true); // Web assumes server is running
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup window close handler to check setting and stop server if needed
|
// Setup window close handler to check setting and stop server if needed
|
||||||
// This works in both dev and prod, but will only stop server if it was started by the app
|
// This works in both dev and prod, but will only stop server if it was started by the app
|
||||||
setupWindowCloseHandler().catch((error) => {
|
platform.lifecycle.setupWindowCloseHandler().catch((error) => {
|
||||||
console.error('Failed to setup window close handler:', error);
|
console.error('Failed to setup window close handler:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,18 +87,21 @@ function App() {
|
|||||||
serverStartingRef.current = true;
|
serverStartingRef.current = true;
|
||||||
console.log('Production mode: Starting bundled server...');
|
console.log('Production mode: Starting bundled server...');
|
||||||
|
|
||||||
startServer(false)
|
platform.lifecycle
|
||||||
|
.startServer(false)
|
||||||
.then((serverUrl) => {
|
.then((serverUrl) => {
|
||||||
console.log('Server is ready at:', serverUrl);
|
console.log('Server is ready at:', serverUrl);
|
||||||
// Update the server URL in the store with the dynamically assigned port
|
// Update the server URL in the store with the dynamically assigned port
|
||||||
useServerStore.getState().setServerUrl(serverUrl);
|
useServerStore.getState().setServerUrl(serverUrl);
|
||||||
setServerReady(true);
|
setServerReady(true);
|
||||||
// Mark that we started the server (so we know to stop it on close)
|
// Mark that we started the server (so we know to stop it on close)
|
||||||
|
// @ts-expect-error - adding property to window
|
||||||
window.__voiceboxServerStartedByApp = true;
|
window.__voiceboxServerStartedByApp = true;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to auto-start server:', error);
|
console.error('Failed to auto-start server:', error);
|
||||||
serverStartingRef.current = false;
|
serverStartingRef.current = false;
|
||||||
|
// @ts-expect-error - adding property to window
|
||||||
window.__voiceboxServerStartedByApp = false;
|
window.__voiceboxServerStartedByApp = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,11 +111,11 @@ function App() {
|
|||||||
// Window close event handles server shutdown based on setting
|
// Window close event handles server shutdown based on setting
|
||||||
serverStartingRef.current = false;
|
serverStartingRef.current = false;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [platform]);
|
||||||
|
|
||||||
// Cycle through loading messages every 3 seconds
|
// Cycle through loading messages every 3 seconds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTauri() || serverReady) {
|
if (!platform.metadata.isTauri || serverReady) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,10 +124,10 @@ function App() {
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [serverReady]);
|
}, [serverReady, platform.metadata.isTauri]);
|
||||||
|
|
||||||
// Show loading screen while server is starting in Tauri
|
// Show loading screen while server is starting in Tauri
|
||||||
if (isTauri() && !serverReady) {
|
if (platform.metadata.isTauri && !serverReady) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
|
||||||
import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react';
|
import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import WaveSurfer from 'wavesurfer.js';
|
import WaveSurfer from 'wavesurfer.js';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { isTauri } from '@/lib/tauri';
|
|
||||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||||
import { debug } from '@/lib/utils/debug';
|
import { debug } from '@/lib/utils/debug';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
|
||||||
export function AudioPlayer() {
|
export function AudioPlayer() {
|
||||||
|
const platform = usePlatform();
|
||||||
const {
|
const {
|
||||||
audioUrl,
|
audioUrl,
|
||||||
audioId,
|
audioId,
|
||||||
@@ -39,7 +39,7 @@ export function AudioPlayer() {
|
|||||||
if (!profileId) return { channel_ids: [] };
|
if (!profileId) return { channel_ids: [] };
|
||||||
return apiClient.getProfileChannels(profileId);
|
return apiClient.getProfileChannels(profileId);
|
||||||
},
|
},
|
||||||
enabled: !!profileId && isTauri(),
|
enabled: !!profileId && platform.metadata.isTauri,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: channels } = useQuery({
|
const { data: channels } = useQuery({
|
||||||
@@ -50,7 +50,7 @@ export function AudioPlayer() {
|
|||||||
|
|
||||||
// Determine if we should use native playback
|
// Determine if we should use native playback
|
||||||
const useNativePlayback = useMemo(() => {
|
const useNativePlayback = useMemo(() => {
|
||||||
if (!isTauri() || !profileChannels || !channels) {
|
if (!platform.metadata.isTauri || !profileChannels || !channels) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ export function AudioPlayer() {
|
|||||||
let runtimeProfileChannels = null;
|
let runtimeProfileChannels = null;
|
||||||
let runtimeChannels = null;
|
let runtimeChannels = null;
|
||||||
|
|
||||||
if (isTauri() && currentProfileId) {
|
if (platform.metadata.isTauri && currentProfileId) {
|
||||||
try {
|
try {
|
||||||
runtimeProfileChannels = await apiClient.getProfileChannels(currentProfileId);
|
runtimeProfileChannels = await apiClient.getProfileChannels(currentProfileId);
|
||||||
debug.log('Runtime profileChannels:', runtimeProfileChannels);
|
debug.log('Runtime profileChannels:', runtimeProfileChannels);
|
||||||
@@ -210,7 +210,7 @@ export function AudioPlayer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
debug.log('Auto-play check:', {
|
debug.log('Auto-play check:', {
|
||||||
isTauri: isTauri(),
|
isTauri: platform.metadata.isTauri,
|
||||||
currentAudioUrl,
|
currentAudioUrl,
|
||||||
currentProfileId,
|
currentProfileId,
|
||||||
hasProfileChannels: !!runtimeProfileChannels,
|
hasProfileChannels: !!runtimeProfileChannels,
|
||||||
@@ -218,7 +218,7 @@ export function AudioPlayer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isTauri() &&
|
platform.metadata.isTauri &&
|
||||||
currentAudioUrl &&
|
currentAudioUrl &&
|
||||||
currentProfileId &&
|
currentProfileId &&
|
||||||
runtimeProfileChannels &&
|
runtimeProfileChannels &&
|
||||||
@@ -229,7 +229,7 @@ export function AudioPlayer() {
|
|||||||
// Stop any existing native playback first
|
// Stop any existing native playback first
|
||||||
if (isUsingNativePlaybackRef.current) {
|
if (isUsingNativePlaybackRef.current) {
|
||||||
try {
|
try {
|
||||||
await invoke('stop_audio_playback');
|
platform.audio.stopPlayback();
|
||||||
debug.log('Stopped existing native playback before starting new one');
|
debug.log('Stopped existing native playback before starting new one');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('Failed to stop existing playback:', error);
|
debug.error('Failed to stop existing playback:', error);
|
||||||
@@ -279,11 +279,8 @@ export function AudioPlayer() {
|
|||||||
// Play via native audio
|
// Play via native audio
|
||||||
debug.log('Invoking play_audio_to_devices...');
|
debug.log('Invoking play_audio_to_devices...');
|
||||||
try {
|
try {
|
||||||
const result = await invoke('play_audio_to_devices', {
|
await platform.audio.playToDevices(audioData, deviceIds);
|
||||||
audioData: Array.from(audioData),
|
debug.log('play_audio_to_devices completed successfully');
|
||||||
deviceIds: deviceIds,
|
|
||||||
});
|
|
||||||
debug.log('play_audio_to_devices completed successfully, result:', result);
|
|
||||||
|
|
||||||
// Mark that we're using native playback
|
// Mark that we're using native playback
|
||||||
isUsingNativePlaybackRef.current = true;
|
isUsingNativePlaybackRef.current = true;
|
||||||
@@ -516,15 +513,13 @@ export function AudioPlayer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Stop native playback if it was active
|
// Stop native playback if it was active
|
||||||
if (isUsingNativePlaybackRef.current && isTauri()) {
|
if (isUsingNativePlaybackRef.current && platform.metadata.isTauri) {
|
||||||
(async () => {
|
try {
|
||||||
try {
|
platform.audio.stopPlayback();
|
||||||
await invoke('stop_audio_playback');
|
debug.log('Stopped native audio playback');
|
||||||
debug.log('Stopped native audio playback');
|
} catch (error) {
|
||||||
} catch (error) {
|
debug.error('Failed to stop native playback:', error);
|
||||||
debug.error('Failed to stop native playback:', error);
|
}
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset native playback flag when loading new audio
|
// Reset native playback flag when loading new audio
|
||||||
@@ -711,7 +706,7 @@ export function AudioPlayer() {
|
|||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
// Pause: stop native playback and pause WaveSurfer visualization
|
// Pause: stop native playback and pause WaveSurfer visualization
|
||||||
try {
|
try {
|
||||||
await invoke('stop_audio_playback');
|
platform.audio.stopPlayback();
|
||||||
debug.log('Stopped native audio playback');
|
debug.log('Stopped native audio playback');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('Failed to stop native playback:', error);
|
debug.error('Failed to stop native playback:', error);
|
||||||
@@ -724,7 +719,7 @@ export function AudioPlayer() {
|
|||||||
try {
|
try {
|
||||||
// Stop any existing native playback first
|
// Stop any existing native playback first
|
||||||
try {
|
try {
|
||||||
await invoke('stop_audio_playback');
|
platform.audio.stopPlayback();
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// Ignore errors when stopping (might not be playing)
|
// Ignore errors when stopping (might not be playing)
|
||||||
debug.log('No existing playback to stop');
|
debug.log('No existing playback to stop');
|
||||||
@@ -742,10 +737,7 @@ export function AudioPlayer() {
|
|||||||
const audioData = new Uint8Array(await response.arrayBuffer());
|
const audioData = new Uint8Array(await response.arrayBuffer());
|
||||||
|
|
||||||
// Play via native audio
|
// Play via native audio
|
||||||
await invoke('play_audio_to_devices', {
|
await platform.audio.playToDevices(audioData, deviceIds);
|
||||||
audioData: Array.from(audioData),
|
|
||||||
deviceIds: deviceIds,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mark that we're using native playback
|
// Mark that we're using native playback
|
||||||
isUsingNativePlaybackRef.current = true;
|
isUsingNativePlaybackRef.current = true;
|
||||||
@@ -806,10 +798,12 @@ export function AudioPlayer() {
|
|||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
// Stop any native playback
|
// Stop any native playback
|
||||||
if (isUsingNativePlaybackRef.current && isTauri()) {
|
if (isUsingNativePlaybackRef.current && platform.metadata.isTauri) {
|
||||||
invoke('stop_audio_playback').catch((error) => {
|
try {
|
||||||
|
platform.audio.stopPlayback();
|
||||||
|
} catch (error) {
|
||||||
debug.error('Failed to stop native playback:', error);
|
debug.error('Failed to stop native playback:', error);
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
// Stop WaveSurfer
|
// Stop WaveSurfer
|
||||||
if (wavesurferRef.current) {
|
if (wavesurferRef.current) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
|
||||||
import { Check, CheckCircle2, Edit, Plus, Speaker, Trash2 } from 'lucide-react';
|
import { Check, CheckCircle2, Edit, Plus, Speaker, Trash2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -23,9 +22,9 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import { isTauri } from '@/lib/tauri';
|
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
|
||||||
interface AudioDevice {
|
interface AudioDevice {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -34,6 +33,7 @@ interface AudioDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AudioTab() {
|
export function AudioTab() {
|
||||||
|
const platform = usePlatform();
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
const [editingChannel, setEditingChannel] = useState<string | null>(null);
|
const [editingChannel, setEditingChannel] = useState<string | null>(null);
|
||||||
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
|
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
|
||||||
@@ -49,18 +49,17 @@ export function AudioTab() {
|
|||||||
const { data: devices, isLoading: devicesLoading } = useQuery({
|
const { data: devices, isLoading: devicesLoading } = useQuery({
|
||||||
queryKey: ['audio-devices'],
|
queryKey: ['audio-devices'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!isTauri()) {
|
if (!platform.metadata.isTauri) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await invoke<AudioDevice[]>('list_audio_output_devices');
|
return await platform.audio.listOutputDevices();
|
||||||
return result;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to list audio devices:', error);
|
console.error('Failed to list audio devices:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled: isTauri(),
|
enabled: platform.metadata.isTauri,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: profiles } = useQuery({
|
const { data: profiles } = useQuery({
|
||||||
@@ -342,7 +341,7 @@ export function AudioTab() {
|
|||||||
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
||||||
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
|
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
<p className="text-muted-foreground text-center">
|
<p className="text-muted-foreground text-center">
|
||||||
{isTauri() ? 'No audio devices found' : 'Audio device selection requires Tauri'}
|
{platform.metadata.isTauri ? 'No audio devices found' : 'Audio device selection requires Tauri'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AudioWaveform, Download, FileArchive, MoreHorizontal, Play, Trash2 } from 'lucide-react';
|
import { AudioWaveform, Download, FileArchive, Loader2, MoreHorizontal, Play, Trash2 } from 'lucide-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import type { HistoryResponse } from '@/lib/api/types';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -33,18 +34,23 @@ import { usePlayerStore } from '@/stores/playerStore';
|
|||||||
// OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history)
|
// OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history)
|
||||||
// This is the new alternate history view with fixed height rows
|
// This is the new alternate history view with fixed height rows
|
||||||
|
|
||||||
// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS
|
// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS WITH INFINITE SCROLL
|
||||||
export function HistoryTable() {
|
export function HistoryTable() {
|
||||||
const [page, _setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
|
const [allHistory, setAllHistory] = useState<HistoryResponse[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [generationToDelete, setGenerationToDelete] = useState<{ id: string; name: string } | null>(null);
|
||||||
const limit = 20;
|
const limit = 20;
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { data: historyData, isLoading } = useHistory({
|
const { data: historyData, isLoading, isFetching } = useHistory({
|
||||||
limit,
|
limit,
|
||||||
offset: page * limit,
|
offset: page * limit,
|
||||||
});
|
});
|
||||||
@@ -60,6 +66,56 @@ export function HistoryTable() {
|
|||||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||||
const isPlayerVisible = !!audioUrl;
|
const isPlayerVisible = !!audioUrl;
|
||||||
|
|
||||||
|
// Update accumulated history when new data arrives
|
||||||
|
useEffect(() => {
|
||||||
|
if (historyData?.items) {
|
||||||
|
setTotal(historyData.total);
|
||||||
|
if (page === 0) {
|
||||||
|
// Reset to first page
|
||||||
|
setAllHistory(historyData.items);
|
||||||
|
} else {
|
||||||
|
// Append new items, avoiding duplicates
|
||||||
|
setAllHistory((prev) => {
|
||||||
|
const existingIds = new Set(prev.map((item) => item.id));
|
||||||
|
const newItems = historyData.items.filter((item) => !existingIds.has(item.id));
|
||||||
|
return [...prev, ...newItems];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [historyData, page]);
|
||||||
|
|
||||||
|
// Reset to page 0 when deletions or imports occur
|
||||||
|
useEffect(() => {
|
||||||
|
if (deleteGeneration.isSuccess || importGeneration.isSuccess) {
|
||||||
|
setPage(0);
|
||||||
|
setAllHistory([]);
|
||||||
|
}
|
||||||
|
}, [deleteGeneration.isSuccess, importGeneration.isSuccess]);
|
||||||
|
|
||||||
|
// Intersection Observer for infinite scroll
|
||||||
|
useEffect(() => {
|
||||||
|
const loadMoreEl = loadMoreRef.current;
|
||||||
|
if (!loadMoreEl) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const target = entries[0];
|
||||||
|
if (target.isIntersecting && !isFetching && allHistory.length < total) {
|
||||||
|
setPage((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: scrollRef.current,
|
||||||
|
rootMargin: '100px',
|
||||||
|
threshold: 0.1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(loadMoreEl);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [isFetching, allHistory.length, total]);
|
||||||
|
|
||||||
|
// Track scroll position for gradient effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scrollEl = scrollRef.current;
|
const scrollEl = scrollRef.current;
|
||||||
if (!scrollEl) return;
|
if (!scrollEl) return;
|
||||||
@@ -113,24 +169,16 @@ export function HistoryTable() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const _handleImportClick = () => {
|
const handleDeleteClick = (generationId: string, profileName: string) => {
|
||||||
file_handleImportClickk.click();
|
setGenerationToDelete({ id: generationId, name: profileName });
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const _handleFileChange = (_e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleDeleteConfirm = () => {
|
||||||
cons_handleFileChangeet.files?.[0];
|
if (generationToDelete) {
|
||||||
if (file) {
|
deleteGeneration.mutate(generationToDelete.id);
|
||||||
// Validate file extension
|
setDeleteDialogOpen(false);
|
||||||
if (!file.name.endsWith('.voicebox.zip')) {
|
setGenerationToDelete(null);
|
||||||
toast({
|
|
||||||
title: 'Invalid file type',
|
|
||||||
description: 'Please select a valid .voicebox.zip file',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedFile(file);
|
|
||||||
setImportDialogOpen(true);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -159,13 +207,16 @@ export function HistoryTable() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading && page === 0) {
|
||||||
return null;
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const history = historyData?.items || [];
|
const history = allHistory;
|
||||||
const total = historyData?.total || 0;
|
const hasMore = allHistory.length < total;
|
||||||
const _hasMore = history.length === limit && (page + 1) * limit < total;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0 relative">
|
<div className="flex flex-col h-full min-h-0 relative">
|
||||||
@@ -271,7 +322,7 @@ export function HistoryTable() {
|
|||||||
Export Package
|
Export Package
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => deleteGeneration.mutate(gen.id)}
|
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
|
||||||
disabled={deleteGeneration.isPending}
|
disabled={deleteGeneration.isPending}
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
>
|
>
|
||||||
@@ -284,10 +335,53 @@ export function HistoryTable() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Load more trigger element */}
|
||||||
|
{hasMore && (
|
||||||
|
<div ref={loadMoreRef} className="flex items-center justify-center py-4">
|
||||||
|
{isFetching && <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* End of list indicator */}
|
||||||
|
{!hasMore && history.length > 0 && (
|
||||||
|
<div className="text-center py-4 text-xs text-muted-foreground">
|
||||||
|
You've reached the end
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Generation</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete this generation from "{generationToDelete?.name}"? This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setGenerationToDelete(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
disabled={deleteGeneration.isPending}
|
||||||
|
>
|
||||||
|
{deleteGeneration.isPending ? 'Deleting...' : 'Delete'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
import { setKeepServerRunning } from '@/lib/tauri';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
|
||||||
const connectionSchema = z.object({
|
const connectionSchema = z.object({
|
||||||
serverUrl: z.string().url('Please enter a valid URL'),
|
serverUrl: z.string().url('Please enter a valid URL'),
|
||||||
@@ -26,6 +26,7 @@ const connectionSchema = z.object({
|
|||||||
type ConnectionFormValues = z.infer<typeof connectionSchema>;
|
type ConnectionFormValues = z.infer<typeof connectionSchema>;
|
||||||
|
|
||||||
export function ConnectionForm() {
|
export function ConnectionForm() {
|
||||||
|
const platform = usePlatform();
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
const setServerUrl = useServerStore((state) => state.setServerUrl);
|
const setServerUrl = useServerStore((state) => state.setServerUrl);
|
||||||
const keepServerRunningOnClose = useServerStore((state) => state.keepServerRunningOnClose);
|
const keepServerRunningOnClose = useServerStore((state) => state.keepServerRunningOnClose);
|
||||||
@@ -89,7 +90,7 @@ export function ConnectionForm() {
|
|||||||
checked={keepServerRunningOnClose}
|
checked={keepServerRunningOnClose}
|
||||||
onCheckedChange={(checked: boolean) => {
|
onCheckedChange={(checked: boolean) => {
|
||||||
setKeepServerRunningOnClose(checked);
|
setKeepServerRunningOnClose(checked);
|
||||||
setKeepServerRunning(checked).catch((error) => {
|
platform.lifecycle.setKeepServerRunning(checked).catch((error) => {
|
||||||
console.error('Failed to sync setting to Rust:', error);
|
console.error('Failed to sync setting to Rust:', error);
|
||||||
});
|
});
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { getVersion } from '@tauri-apps/api/app';
|
|
||||||
import { AlertCircle, Download, RefreshCw } from 'lucide-react';
|
import { AlertCircle, Download, RefreshCw } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -6,16 +5,18 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
|
||||||
export function UpdateStatus() {
|
export function UpdateStatus() {
|
||||||
|
const platform = usePlatform();
|
||||||
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
|
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
|
||||||
const [currentVersion, setCurrentVersion] = useState<string>('');
|
const [currentVersion, setCurrentVersion] = useState<string>('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getVersion()
|
platform.metadata.getVersion()
|
||||||
.then(setCurrentVersion)
|
.then(setCurrentVersion)
|
||||||
.catch(() => setCurrentVersion('0.1.0'));
|
.catch(() => setCurrentVersion('0.1.0'));
|
||||||
}, []);
|
}, [platform]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
|
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
|
||||||
import { ServerStatus } from '@/components/ServerSettings/ServerStatus';
|
import { ServerStatus } from '@/components/ServerSettings/ServerStatus';
|
||||||
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
|
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
|
||||||
import { isTauri } from '@/lib/tauri';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
|
||||||
export function ServerTab() {
|
export function ServerTab() {
|
||||||
|
const platform = usePlatform();
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 overflow-y-auto flex flex-col">
|
<div className="space-y-4 overflow-y-auto flex flex-col">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<ConnectionForm />
|
<ConnectionForm />
|
||||||
<ServerStatus />
|
<ServerStatus />
|
||||||
</div>
|
</div>
|
||||||
{isTauri() && <UpdateStatus />}
|
{platform.metadata.isTauri && <UpdateStatus />}
|
||||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
Created by{' '}
|
Created by{' '}
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Download, Edit, Mic, Trash2 } from 'lucide-react';
|
import { Download, Edit, Mic, Trash2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -16,6 +15,7 @@ import {
|
|||||||
import type { VoiceProfileResponse } from '@/lib/api/types';
|
import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||||
import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
|
import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
|
||||||
interface ProfileCardProps {
|
interface ProfileCardProps {
|
||||||
@@ -35,9 +35,7 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
|||||||
|
|
||||||
const isSelected = selectedProfileId === profile.id;
|
const isSelected = selectedProfileId === profile.id;
|
||||||
|
|
||||||
const avatarUrl = profile.avatar_path
|
const avatarUrl = profile.avatar_path ? `${serverUrl}/profiles/${profile.id}/avatar` : null;
|
||||||
? `${serverUrl}/profiles/${profile.id}/avatar`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const handleSelect = () => {
|
const handleSelect = () => {
|
||||||
setSelectedProfileId(isSelected ? null : profile.id);
|
setSelectedProfileId(isSelected ? null : profile.id);
|
||||||
@@ -81,7 +79,7 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
|||||||
alt={`${profile.name} avatar`}
|
alt={`${profile.name} avatar`}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-full w-full object-cover transition-all duration-200',
|
'h-full w-full object-cover transition-all duration-200',
|
||||||
!isSelected && 'grayscale'
|
!isSelected && 'grayscale',
|
||||||
)}
|
)}
|
||||||
onError={() => setAvatarError(true)}
|
onError={() => setAvatarError(true)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ import {
|
|||||||
} from '@/lib/hooks/useProfiles';
|
} from '@/lib/hooks/useProfiles';
|
||||||
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
||||||
import { useTranscription } from '@/lib/hooks/useTranscription';
|
import { useTranscription } from '@/lib/hooks/useTranscription';
|
||||||
import { isTauri } from '@/lib/tauri';
|
|
||||||
import { formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
|
import { formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
|
||||||
import { type ProfileFormDraft, useUIStore } from '@/stores/uiStore';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
import { type ProfileFormDraft, useUIStore } from '@/stores/uiStore';
|
||||||
import { AudioSampleRecording } from './AudioSampleRecording';
|
import { AudioSampleRecording } from './AudioSampleRecording';
|
||||||
import { AudioSampleSystem } from './AudioSampleSystem';
|
import { AudioSampleSystem } from './AudioSampleSystem';
|
||||||
import { AudioSampleUpload } from './AudioSampleUpload';
|
import { AudioSampleUpload } from './AudioSampleUpload';
|
||||||
@@ -102,6 +102,7 @@ function base64ToFile(base64: string, fileName: string, fileType: string): File
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileForm() {
|
export function ProfileForm() {
|
||||||
|
const platform = usePlatform();
|
||||||
const open = useUIStore((state) => state.profileDialogOpen);
|
const open = useUIStore((state) => state.profileDialogOpen);
|
||||||
const setOpen = useUIStore((state) => state.setProfileDialogOpen);
|
const setOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||||
const editingProfileId = useUIStore((state) => state.editingProfileId);
|
const editingProfileId = useUIStore((state) => state.editingProfileId);
|
||||||
@@ -427,7 +428,8 @@ export function ProfileForm() {
|
|||||||
} catch (avatarError) {
|
} catch (avatarError) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Avatar upload failed',
|
title: 'Avatar upload failed',
|
||||||
description: avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
|
description:
|
||||||
|
avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -520,7 +522,8 @@ export function ProfileForm() {
|
|||||||
} catch (avatarError) {
|
} catch (avatarError) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Avatar upload failed',
|
title: 'Avatar upload failed',
|
||||||
description: avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
|
description:
|
||||||
|
avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -662,7 +665,7 @@ export function ProfileForm() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TabsList
|
<TabsList
|
||||||
className={`grid w-full ${isTauri() && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
className={`grid w-full ${platform.metadata.isTauri && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||||
>
|
>
|
||||||
<TabsTrigger value="upload" className="flex items-center gap-2">
|
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||||
<Upload className="h-4 w-4 shrink-0" />
|
<Upload className="h-4 w-4 shrink-0" />
|
||||||
@@ -672,7 +675,7 @@ export function ProfileForm() {
|
|||||||
<Mic className="h-4 w-4 shrink-0" />
|
<Mic className="h-4 w-4 shrink-0" />
|
||||||
Record
|
Record
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{isTauri() && isSystemAudioSupported && (
|
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||||
<TabsTrigger value="system" className="flex items-center gap-2">
|
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||||
<Monitor className="h-4 w-4 shrink-0" />
|
<Monitor className="h-4 w-4 shrink-0" />
|
||||||
System Audio
|
System Audio
|
||||||
@@ -724,7 +727,7 @@ export function ProfileForm() {
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{isTauri() && isSystemAudioSupported && (
|
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||||
<TabsContent value="system" className="space-y-4">
|
<TabsContent value="system" className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { Plus, Trash2, Play, Edit, Check, X, Volume2, Pause } from 'lucide-react';
|
import { Check, Edit, Pause, Play, Plus, Trash2, Volume2, X } from 'lucide-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { CircleButton } from '@/components/ui/circle-button';
|
import { CircleButton } from '@/components/ui/circle-button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useDeleteSample, useProfileSamples, useUpdateSample } from '@/lib/hooks/useProfiles';
|
import { useDeleteSample, useProfileSamples, useUpdateSample } from '@/lib/hooks/useProfiles';
|
||||||
@@ -140,10 +148,19 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
const [uploadOpen, setUploadOpen] = useState(false);
|
const [uploadOpen, setUploadOpen] = useState(false);
|
||||||
const [editingSampleId, setEditingSampleId] = useState<string | null>(null);
|
const [editingSampleId, setEditingSampleId] = useState<string | null>(null);
|
||||||
const [editedText, setEditedText] = useState<string>('');
|
const [editedText, setEditedText] = useState<string>('');
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [sampleToDelete, setSampleToDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleDelete = (sampleId: string) => {
|
const handleDeleteClick = (sampleId: string) => {
|
||||||
if (confirm('Are you sure you want to delete this sample?')) {
|
setSampleToDelete(sampleId);
|
||||||
deleteSample.mutate(sampleId);
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = () => {
|
||||||
|
if (sampleToDelete) {
|
||||||
|
deleteSample.mutate(sampleToDelete);
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setSampleToDelete(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -194,7 +211,9 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
<div className="flex flex-col items-center justify-center py-8 text-center border border-dashed rounded-lg">
|
<div className="flex flex-col items-center justify-center py-8 text-center border border-dashed rounded-lg">
|
||||||
<Volume2 className="h-8 w-8 text-muted-foreground/50 mb-2" />
|
<Volume2 className="h-8 w-8 text-muted-foreground/50 mb-2" />
|
||||||
<p className="text-sm text-muted-foreground">No samples yet</p>
|
<p className="text-sm text-muted-foreground">No samples yet</p>
|
||||||
<p className="text-xs text-muted-foreground/70 mt-1">Add your first audio sample to get started</p>
|
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||||
|
Add your first audio sample to get started
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -206,7 +225,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
key={sample.id}
|
key={sample.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative rounded-lg border bg-card transition-all duration-200',
|
'group relative rounded-lg border bg-card transition-all duration-200',
|
||||||
isEditing ? 'ring-2 ring-primary/20' : 'hover:border-primary/30'
|
isEditing ? 'ring-2 ring-primary/20' : 'hover:border-primary/30',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
@@ -266,7 +285,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
<CircleButton
|
<CircleButton
|
||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
title="Delete sample"
|
title="Delete sample"
|
||||||
onClick={() => handleDelete(sample.id)}
|
onClick={() => handleDeleteClick(sample.id)}
|
||||||
disabled={deleteSample.isPending}
|
disabled={deleteSample.isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -287,12 +306,52 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button type="button" variant="outline" className="w-full" onClick={() => setUploadOpen(true)}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setUploadOpen(true)}
|
||||||
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Sample
|
Add Sample
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground text-center px-2">
|
||||||
|
Note: A single 30-second sample is the sweet spot. Quality may decrease with multiple
|
||||||
|
samples. In a future update samples might be interchangeable and tagged for varying styles
|
||||||
|
of the same voice.
|
||||||
|
</p>
|
||||||
|
|
||||||
<SampleUpload profileId={profileId} open={uploadOpen} onOpenChange={setUploadOpen} />
|
<SampleUpload profileId={profileId} open={uploadOpen} onOpenChange={setUploadOpen} />
|
||||||
|
|
||||||
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Sample</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete this audio sample? This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setSampleToDelete(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
disabled={deleteSample.isPending}
|
||||||
|
>
|
||||||
|
{deleteSample.isPending ? 'Deleting...' : 'Delete'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
|||||||
import { useAddSample, useProfile } from '@/lib/hooks/useProfiles';
|
import { useAddSample, useProfile } from '@/lib/hooks/useProfiles';
|
||||||
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
||||||
import { useTranscription } from '@/lib/hooks/useTranscription';
|
import { useTranscription } from '@/lib/hooks/useTranscription';
|
||||||
import { isTauri } from '@/lib/tauri';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
import { AudioSampleRecording } from './AudioSampleRecording';
|
import { AudioSampleRecording } from './AudioSampleRecording';
|
||||||
import { AudioSampleSystem } from './AudioSampleSystem';
|
import { AudioSampleSystem } from './AudioSampleSystem';
|
||||||
import { AudioSampleUpload } from './AudioSampleUpload';
|
import { AudioSampleUpload } from './AudioSampleUpload';
|
||||||
@@ -49,6 +49,7 @@ interface SampleUploadProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProps) {
|
export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProps) {
|
||||||
|
const platform = usePlatform();
|
||||||
const addSample = useAddSample();
|
const addSample = useAddSample();
|
||||||
const transcribe = useTranscription();
|
const transcribe = useTranscription();
|
||||||
const { data: profile } = useProfile(profileId);
|
const { data: profile } = useProfile(profileId);
|
||||||
@@ -232,7 +233,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<Tabs value={mode} onValueChange={(v) => setMode(v as 'upload' | 'record' | 'system')}>
|
<Tabs value={mode} onValueChange={(v) => setMode(v as 'upload' | 'record' | 'system')}>
|
||||||
<TabsList
|
<TabsList
|
||||||
className={`grid w-full ${isTauri() && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
className={`grid w-full ${platform.metadata.isTauri && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||||
>
|
>
|
||||||
<TabsTrigger value="upload" className="flex items-center gap-2">
|
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||||
<Upload className="h-4 w-4 shrink-0" />
|
<Upload className="h-4 w-4 shrink-0" />
|
||||||
@@ -242,7 +243,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
<Mic className="h-4 w-4 shrink-0" />
|
<Mic className="h-4 w-4 shrink-0" />
|
||||||
Record
|
Record
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{isTauri() && isSystemAudioSupported && (
|
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||||
<TabsTrigger value="system" className="flex items-center gap-2">
|
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||||
<Monitor className="h-4 w-4 shrink-0" />
|
<Monitor className="h-4 w-4 shrink-0" />
|
||||||
System Audio
|
System Audio
|
||||||
@@ -289,7 +290,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{isTauri() && isSystemAudioSupported && (
|
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||||
<TabsContent value="system" className="space-y-4">
|
<TabsContent value="system" className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ export interface CircleButtonProps extends React.ButtonHTMLAttributes<HTMLButton
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CircleButton = React.forwardRef<HTMLButtonElement, CircleButtonProps>(
|
const CircleButton = React.forwardRef<HTMLButtonElement, CircleButtonProps>(
|
||||||
({ className, icon: Icon, ...props }, ref) => {
|
({ className, icon: Icon, type = 'button', ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-7 w-7 rounded-full flex items-center justify-center flex-shrink-0',
|
'h-7 w-7 rounded-full flex items-center justify-center flex-shrink-0',
|
||||||
'hover:bg-muted transition-colors',
|
'hover:bg-muted transition-colors',
|
||||||
|
|||||||
+25
-156
@@ -1,172 +1,41 @@
|
|||||||
import { relaunch } from '@tauri-apps/plugin-process';
|
|
||||||
import { check, type Update } from '@tauri-apps/plugin-updater';
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
import type { UpdateStatus } from '@/platform/types';
|
||||||
|
|
||||||
export interface UpdateStatus {
|
// Re-export UpdateStatus for backwards compatibility
|
||||||
checking: boolean;
|
export type { UpdateStatus };
|
||||||
available: boolean;
|
|
||||||
version?: string;
|
|
||||||
downloading: boolean;
|
|
||||||
installing: boolean;
|
|
||||||
readyToInstall: boolean;
|
|
||||||
error?: string;
|
|
||||||
downloadProgress?: number; // 0-100 percentage
|
|
||||||
downloadedBytes?: number;
|
|
||||||
totalBytes?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we're on Windows (NSIS installer handles restart automatically)
|
|
||||||
const isWindows = () => {
|
|
||||||
return navigator.userAgent.includes('Windows');
|
|
||||||
};
|
|
||||||
|
|
||||||
const isTauri = () => {
|
|
||||||
return '__TAURI_INTERNALS__' in window;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useAutoUpdater(checkOnMount = false) {
|
export function useAutoUpdater(checkOnMount = false) {
|
||||||
const [status, setStatus] = useState<UpdateStatus>({
|
const platform = usePlatform();
|
||||||
checking: false,
|
const [status, setStatus] = useState<UpdateStatus>(
|
||||||
available: false,
|
platform.updater.getStatus(),
|
||||||
downloading: false,
|
);
|
||||||
installing: false,
|
|
||||||
readyToInstall: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [update, setUpdate] = useState<Update | null>(null);
|
// Subscribe to updater status changes
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = platform.updater.subscribe((newStatus) => {
|
||||||
|
setStatus(newStatus);
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
}, [platform]);
|
||||||
|
|
||||||
const checkForUpdates = useCallback(async () => {
|
const checkForUpdates = useCallback(async () => {
|
||||||
if (!isTauri()) {
|
await platform.updater.checkForUpdates();
|
||||||
return;
|
}, [platform]);
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
const downloadAndInstall = useCallback(async () => {
|
||||||
setStatus((prev) => ({ ...prev, checking: true, error: undefined }));
|
await platform.updater.downloadAndInstall();
|
||||||
|
}, [platform]);
|
||||||
|
|
||||||
const foundUpdate = await check();
|
const restartAndInstall = useCallback(async () => {
|
||||||
|
await platform.updater.restartAndInstall();
|
||||||
if (foundUpdate?.available) {
|
}, [platform]);
|
||||||
setUpdate(foundUpdate);
|
|
||||||
setStatus({
|
|
||||||
checking: false,
|
|
||||||
available: true,
|
|
||||||
version: foundUpdate.version,
|
|
||||||
downloading: false,
|
|
||||||
installing: false,
|
|
||||||
readyToInstall: false,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setStatus({
|
|
||||||
checking: false,
|
|
||||||
available: false,
|
|
||||||
downloading: false,
|
|
||||||
installing: false,
|
|
||||||
readyToInstall: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setStatus({
|
|
||||||
checking: false,
|
|
||||||
available: false,
|
|
||||||
downloading: false,
|
|
||||||
installing: false,
|
|
||||||
readyToInstall: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to check for updates',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Download the update (but don't install yet)
|
|
||||||
const downloadAndInstall = async () => {
|
|
||||||
if (!update || !isTauri()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setStatus((prev) => ({ ...prev, downloading: true, error: undefined }));
|
|
||||||
|
|
||||||
let downloadedBytes = 0;
|
|
||||||
let totalBytes = 0;
|
|
||||||
|
|
||||||
// Just download the update
|
|
||||||
await update.download((event) => {
|
|
||||||
switch (event.event) {
|
|
||||||
case 'Started':
|
|
||||||
totalBytes = event.data.contentLength || 0;
|
|
||||||
downloadedBytes = 0;
|
|
||||||
setStatus((prev) => ({
|
|
||||||
...prev,
|
|
||||||
downloading: true,
|
|
||||||
totalBytes,
|
|
||||||
downloadedBytes: 0,
|
|
||||||
downloadProgress: 0,
|
|
||||||
}));
|
|
||||||
break;
|
|
||||||
case 'Progress': {
|
|
||||||
downloadedBytes += event.data.chunkLength;
|
|
||||||
const progress =
|
|
||||||
totalBytes > 0 ? Math.round((downloadedBytes / totalBytes) * 100) : undefined;
|
|
||||||
setStatus((prev) => ({
|
|
||||||
...prev,
|
|
||||||
downloadedBytes,
|
|
||||||
downloadProgress: progress,
|
|
||||||
}));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'Finished':
|
|
||||||
setStatus((prev) => ({
|
|
||||||
...prev,
|
|
||||||
downloading: false,
|
|
||||||
readyToInstall: true,
|
|
||||||
downloadProgress: 100,
|
|
||||||
}));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
setStatus((prev) => ({
|
|
||||||
...prev,
|
|
||||||
downloading: false,
|
|
||||||
installing: false,
|
|
||||||
readyToInstall: false,
|
|
||||||
downloadProgress: undefined,
|
|
||||||
downloadedBytes: undefined,
|
|
||||||
totalBytes: undefined,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to download update',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Install the downloaded update and restart the app
|
|
||||||
const restartAndInstall = async () => {
|
|
||||||
if (!update || !isTauri()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setStatus((prev) => ({ ...prev, installing: true, error: undefined }));
|
|
||||||
|
|
||||||
// Install the update
|
|
||||||
await update.install();
|
|
||||||
|
|
||||||
// On Windows with NSIS, the installer handles the restart automatically.
|
|
||||||
// The process will be killed by the NSIS installer, so we won't reach here.
|
|
||||||
// On macOS/Linux, we need to manually relaunch.
|
|
||||||
if (!isWindows()) {
|
|
||||||
await relaunch();
|
|
||||||
}
|
|
||||||
// If we're on Windows and somehow still running, the NSIS installer
|
|
||||||
// should have already handled everything. Just wait for the process to end.
|
|
||||||
} catch (error) {
|
|
||||||
setStatus((prev) => ({
|
|
||||||
...prev,
|
|
||||||
installing: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to install update',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (checkOnMount && isTauri()) {
|
if (checkOnMount && platform.metadata.isTauri) {
|
||||||
checkForUpdates();
|
checkForUpdates();
|
||||||
}
|
}
|
||||||
}, [checkOnMount, checkForUpdates]);
|
}, [checkOnMount, checkForUpdates, platform.metadata.isTauri]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { isTauri } from '@/lib/tauri';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
import { convertToWav } from '@/lib/utils/audio';
|
import { convertToWav } from '@/lib/utils/audio';
|
||||||
|
|
||||||
interface UseAudioRecordingOptions {
|
interface UseAudioRecordingOptions {
|
||||||
@@ -11,6 +11,7 @@ export function useAudioRecording({
|
|||||||
maxDurationSeconds = 29,
|
maxDurationSeconds = 29,
|
||||||
onRecordingComplete,
|
onRecordingComplete,
|
||||||
}: UseAudioRecordingOptions = {}) {
|
}: UseAudioRecordingOptions = {}) {
|
||||||
|
const platform = usePlatform();
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -40,15 +41,14 @@ export function useAudioRecording({
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
const isTauriEnv = isTauri();
|
|
||||||
console.error('MediaDevices check:', {
|
console.error('MediaDevices check:', {
|
||||||
hasNavigator: typeof navigator !== 'undefined',
|
hasNavigator: typeof navigator !== 'undefined',
|
||||||
hasMediaDevices: !!navigator?.mediaDevices,
|
hasMediaDevices: !!navigator?.mediaDevices,
|
||||||
hasGetUserMedia: !!navigator?.mediaDevices?.getUserMedia,
|
hasGetUserMedia: !!navigator?.mediaDevices?.getUserMedia,
|
||||||
isTauri: isTauriEnv,
|
isTauri: platform.metadata.isTauri,
|
||||||
});
|
});
|
||||||
|
|
||||||
const errorMsg = isTauriEnv
|
const errorMsg = platform.metadata.isTauri
|
||||||
? 'Microphone access is not available. Please ensure:\n1. The app has microphone permissions in System Settings (macOS: System Settings > Privacy & Security > Microphone)\n2. You restart the app after granting permissions\n3. You are using Tauri v2 with a webview that supports getUserMedia'
|
? 'Microphone access is not available. Please ensure:\n1. The app has microphone permissions in System Settings (macOS: System Settings > Privacy & Security > Microphone)\n2. You restart the app after granting permissions\n3. You are using Tauri v2 with a webview that supports getUserMedia'
|
||||||
: 'Microphone access is not available. Please ensure you are using a secure context (HTTPS or localhost) and that your browser has microphone permissions enabled.';
|
: 'Microphone access is not available. Please ensure you are using a secure context (HTTPS or localhost) and that your browser has microphone permissions enabled.';
|
||||||
setError(errorMsg);
|
setError(errorMsg);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import type { HistoryQuery } from '@/lib/api/types';
|
import type { HistoryQuery } from '@/lib/api/types';
|
||||||
import { isTauri } from '@/lib/tauri';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
|
||||||
export function useHistory(query?: HistoryQuery) {
|
export function useHistory(query?: HistoryQuery) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -30,116 +30,52 @@ export function useDeleteGeneration() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useExportGeneration() {
|
export function useExportGeneration() {
|
||||||
|
const platform = usePlatform();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
|
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
|
||||||
const blob = await apiClient.exportGeneration(generationId);
|
const blob = await apiClient.exportGeneration(generationId);
|
||||||
|
|
||||||
// Create safe filename from text
|
// Create safe filename from text
|
||||||
const safeText = text.substring(0, 30).replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
const safeText = text
|
||||||
|
.substring(0, 30)
|
||||||
|
.replace(/[^a-z0-9]/gi, '-')
|
||||||
|
.toLowerCase();
|
||||||
const filename = `generation-${safeText}.voicebox.zip`;
|
const filename = `generation-${safeText}.voicebox.zip`;
|
||||||
|
|
||||||
if (isTauri()) {
|
await platform.filesystem.saveFile(filename, blob, [
|
||||||
// Use Tauri's native save dialog
|
{
|
||||||
try {
|
name: 'Voicebox Generation',
|
||||||
const { save } = await import('@tauri-apps/plugin-dialog');
|
extensions: ['zip'],
|
||||||
const filePath = await save({
|
},
|
||||||
defaultPath: filename,
|
]);
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
name: 'Voicebox Generation',
|
|
||||||
extensions: ['voicebox.zip', 'zip'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (filePath) {
|
|
||||||
// Write file using Tauri's filesystem API
|
|
||||||
const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
|
|
||||||
const arrayBuffer = await blob.arrayBuffer();
|
|
||||||
await writeBinaryFile(filePath, new Uint8Array(arrayBuffer));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to use Tauri dialog, falling back to browser download:', error);
|
|
||||||
// Fall back to browser download if Tauri dialog fails
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
document.body.removeChild(a);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Browser: trigger download
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
document.body.removeChild(a);
|
|
||||||
}
|
|
||||||
|
|
||||||
return blob;
|
return blob;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useExportGenerationAudio() {
|
export function useExportGenerationAudio() {
|
||||||
|
const platform = usePlatform();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
|
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
|
||||||
const blob = await apiClient.exportGenerationAudio(generationId);
|
const blob = await apiClient.exportGenerationAudio(generationId);
|
||||||
|
|
||||||
// Create safe filename from text
|
// Create safe filename from text
|
||||||
const safeText = text.substring(0, 30).replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
const safeText = text
|
||||||
|
.substring(0, 30)
|
||||||
|
.replace(/[^a-z0-9]/gi, '-')
|
||||||
|
.toLowerCase();
|
||||||
const filename = `${safeText}.wav`;
|
const filename = `${safeText}.wav`;
|
||||||
|
|
||||||
if (isTauri()) {
|
await platform.filesystem.saveFile(filename, blob, [
|
||||||
// Use Tauri's native save dialog
|
{
|
||||||
try {
|
name: 'Audio File',
|
||||||
const { save } = await import('@tauri-apps/plugin-dialog');
|
extensions: ['wav'],
|
||||||
const filePath = await save({
|
},
|
||||||
defaultPath: filename,
|
]);
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
name: 'Audio File',
|
|
||||||
extensions: ['wav'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (filePath) {
|
|
||||||
// Write file using Tauri's filesystem API
|
|
||||||
const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
|
|
||||||
const arrayBuffer = await blob.arrayBuffer();
|
|
||||||
await writeBinaryFile(filePath, new Uint8Array(arrayBuffer));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to use Tauri dialog, falling back to browser download:', error);
|
|
||||||
// Fall back to browser download if Tauri dialog fails
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
document.body.removeChild(a);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Browser: trigger download
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
document.body.removeChild(a);
|
|
||||||
}
|
|
||||||
|
|
||||||
return blob;
|
return blob;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import type { VoiceProfileCreate } from '@/lib/api/types';
|
import type { VoiceProfileCreate } from '@/lib/api/types';
|
||||||
import { isTauri } from '@/lib/tauri';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
|
||||||
export function useProfiles() {
|
export function useProfiles() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -117,59 +117,24 @@ export function useUpdateSample() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useExportProfile() {
|
export function useExportProfile() {
|
||||||
|
const platform = usePlatform();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (profileId: string) => {
|
mutationFn: async (profileId: string) => {
|
||||||
const blob = await apiClient.exportProfile(profileId);
|
const blob = await apiClient.exportProfile(profileId);
|
||||||
|
|
||||||
// Get profile name for filename
|
// Get profile name for filename
|
||||||
const profile = await apiClient.getProfile(profileId);
|
const profile = await apiClient.getProfile(profileId);
|
||||||
const safeName = profile.name.replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
const safeName = profile.name.replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||||
const filename = `profile-${safeName}.voicebox.zip`;
|
const filename = `profile-${safeName}.voicebox.zip`;
|
||||||
|
|
||||||
if (isTauri()) {
|
await platform.filesystem.saveFile(filename, blob, [
|
||||||
// Use Tauri's native save dialog
|
{
|
||||||
try {
|
name: 'Voicebox Profile',
|
||||||
const { save } = await import('@tauri-apps/plugin-dialog');
|
extensions: ['zip'],
|
||||||
const filePath = await save({
|
},
|
||||||
defaultPath: filename,
|
]);
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
name: 'Voicebox Profile',
|
|
||||||
extensions: ['voicebox.zip', 'zip'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (filePath) {
|
|
||||||
// Write file using Tauri's filesystem API
|
|
||||||
const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
|
|
||||||
const arrayBuffer = await blob.arrayBuffer();
|
|
||||||
await writeBinaryFile(filePath, new Uint8Array(arrayBuffer));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to use Tauri dialog, falling back to browser download:', error);
|
|
||||||
// Fall back to browser download if Tauri dialog fails
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
document.body.removeChild(a);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Browser: trigger download
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
document.body.removeChild(a);
|
|
||||||
}
|
|
||||||
|
|
||||||
return blob;
|
return blob;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import type { StoryCreate, StoryItemCreate, StoryItemBatchUpdate, StoryItemReorder, StoryItemMove, StoryItemTrim, StoryItemSplit } from '@/lib/api/types';
|
import type { StoryCreate, StoryItemCreate, StoryItemBatchUpdate, StoryItemReorder, StoryItemMove, StoryItemTrim, StoryItemSplit } from '@/lib/api/types';
|
||||||
import { isTauri } from '@/lib/tauri';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
|
||||||
export function useStories() {
|
export function useStories() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -158,6 +158,8 @@ export function useDuplicateStoryItem() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useExportStoryAudio() {
|
export function useExportStoryAudio() {
|
||||||
|
const platform = usePlatform();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ storyId, storyName }: { storyId: string; storyName: string }) => {
|
mutationFn: async ({ storyId, storyName }: { storyId: string; storyName: string }) => {
|
||||||
const blob = await apiClient.exportStoryAudio(storyId);
|
const blob = await apiClient.exportStoryAudio(storyId);
|
||||||
@@ -166,49 +168,12 @@ export function useExportStoryAudio() {
|
|||||||
const safeName = storyName.substring(0, 50).replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
const safeName = storyName.substring(0, 50).replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||||
const filename = `${safeName || 'story'}.wav`;
|
const filename = `${safeName || 'story'}.wav`;
|
||||||
|
|
||||||
if (isTauri()) {
|
await platform.filesystem.saveFile(filename, blob, [
|
||||||
// Use Tauri's native save dialog
|
{
|
||||||
try {
|
name: 'Audio File',
|
||||||
const { save } = await import('@tauri-apps/plugin-dialog');
|
extensions: ['wav'],
|
||||||
const filePath = await save({
|
},
|
||||||
defaultPath: filename,
|
]);
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
name: 'Audio File',
|
|
||||||
extensions: ['wav'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (filePath) {
|
|
||||||
// Write file using Tauri's filesystem API
|
|
||||||
const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
|
|
||||||
const arrayBuffer = await blob.arrayBuffer();
|
|
||||||
await writeBinaryFile(filePath, new Uint8Array(arrayBuffer));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to use Tauri dialog, falling back to browser download:', error);
|
|
||||||
// Fall back to browser download if Tauri dialog fails
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
document.body.removeChild(a);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Browser: trigger download
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
document.body.removeChild(a);
|
|
||||||
}
|
|
||||||
|
|
||||||
return blob;
|
return blob;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
import { isTauri } from '@/lib/tauri';
|
|
||||||
|
|
||||||
interface UseSystemAudioCaptureOptions {
|
interface UseSystemAudioCaptureOptions {
|
||||||
maxDurationSeconds?: number;
|
maxDurationSeconds?: number;
|
||||||
@@ -15,6 +14,7 @@ export function useSystemAudioCapture({
|
|||||||
maxDurationSeconds = 29,
|
maxDurationSeconds = 29,
|
||||||
onRecordingComplete,
|
onRecordingComplete,
|
||||||
}: UseSystemAudioCaptureOptions = {}) {
|
}: UseSystemAudioCaptureOptions = {}) {
|
||||||
|
const platform = usePlatform();
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -26,22 +26,12 @@ export function useSystemAudioCapture({
|
|||||||
|
|
||||||
// Check if system audio capture is supported
|
// Check if system audio capture is supported
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTauri()) {
|
const supported = platform.audio.isSystemAudioSupported();
|
||||||
setIsSupported(false);
|
setIsSupported(supported);
|
||||||
return;
|
}, [platform]);
|
||||||
}
|
|
||||||
|
|
||||||
invoke<boolean>('is_system_audio_supported')
|
|
||||||
.then((supported) => {
|
|
||||||
setIsSupported(supported);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setIsSupported(false);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startRecording = useCallback(async () => {
|
const startRecording = useCallback(async () => {
|
||||||
if (!isTauri()) {
|
if (!platform.metadata.isTauri) {
|
||||||
const errorMsg = 'System audio capture is only available in the desktop app.';
|
const errorMsg = 'System audio capture is only available in the desktop app.';
|
||||||
setError(errorMsg);
|
setError(errorMsg);
|
||||||
return;
|
return;
|
||||||
@@ -58,9 +48,7 @@ export function useSystemAudioCapture({
|
|||||||
setDuration(0);
|
setDuration(0);
|
||||||
|
|
||||||
// Start native capture
|
// Start native capture
|
||||||
await invoke('start_system_audio_capture', {
|
await platform.audio.startSystemAudioCapture(maxDurationSeconds);
|
||||||
maxDurationSecs: maxDurationSeconds,
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsRecording(true);
|
setIsRecording(true);
|
||||||
isRecordingRef.current = true;
|
isRecordingRef.current = true;
|
||||||
@@ -86,10 +74,10 @@ export function useSystemAudioCapture({
|
|||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
}
|
}
|
||||||
}, [maxDurationSeconds, isSupported]);
|
}, [maxDurationSeconds, isSupported, platform]);
|
||||||
|
|
||||||
const stopRecording = useCallback(async () => {
|
const stopRecording = useCallback(async () => {
|
||||||
if (!isRecording || !isTauri()) {
|
if (!isRecording || !platform.metadata.isTauri) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,17 +90,9 @@ export function useSystemAudioCapture({
|
|||||||
timerRef.current = null;
|
timerRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop capture and get base64 WAV data
|
// Stop capture and get Blob
|
||||||
const base64Data = await invoke<string>('stop_system_audio_capture');
|
const blob = await platform.audio.stopSystemAudioCapture();
|
||||||
|
|
||||||
// Convert base64 to Blob
|
|
||||||
const binaryString = atob(base64Data);
|
|
||||||
const bytes = new Uint8Array(binaryString.length);
|
|
||||||
for (let i = 0; i < binaryString.length; i++) {
|
|
||||||
bytes[i] = binaryString.charCodeAt(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = new Blob([bytes], { type: 'audio/wav' });
|
|
||||||
// Pass the actual recorded duration
|
// Pass the actual recorded duration
|
||||||
const recordedDuration = startTimeRef.current
|
const recordedDuration = startTimeRef.current
|
||||||
? (Date.now() - startTimeRef.current) / 1000
|
? (Date.now() - startTimeRef.current) / 1000
|
||||||
@@ -125,7 +105,7 @@ export function useSystemAudioCapture({
|
|||||||
: 'Failed to stop system audio capture.';
|
: 'Failed to stop system audio capture.';
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
}
|
}
|
||||||
}, [isRecording, onRecordingComplete]);
|
}, [isRecording, onRecordingComplete, platform]);
|
||||||
|
|
||||||
// Store stopRecording in ref for use in timer
|
// Store stopRecording in ref for use in timer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -155,15 +135,15 @@ export function useSystemAudioCapture({
|
|||||||
timerRef.current = null;
|
timerRef.current = null;
|
||||||
}
|
}
|
||||||
// Cancel recording on unmount if still recording
|
// Cancel recording on unmount if still recording
|
||||||
if (isRecordingRef.current && isTauri()) {
|
if (isRecordingRef.current && platform.metadata.isTauri) {
|
||||||
// Call stop directly without the callback to avoid stale closure
|
// Call stop directly without the callback to avoid stale closure
|
||||||
invoke('stop_system_audio_capture').catch((err) => {
|
platform.audio.stopSystemAudioCapture().catch((err) => {
|
||||||
console.error('Error stopping audio capture on unmount:', err);
|
console.error('Error stopping audio capture on unmount:', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Only run on unmount
|
// biome-ignore lint/correctness/useExhaustiveDependencies: Only run on unmount
|
||||||
}, []);
|
}, [platform]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isRecording,
|
isRecording,
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tauri integration utilities
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
|
||||||
import { listen, emit } from '@tauri-apps/api/event';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if running in Tauri environment
|
|
||||||
*/
|
|
||||||
export function isTauri(): boolean {
|
|
||||||
return '__TAURI_INTERNALS__' in window;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if running on macOS
|
|
||||||
*/
|
|
||||||
export function isMacOS(): boolean {
|
|
||||||
return navigator.platform.toLowerCase().includes('mac');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set whether the server should keep running when the app closes (Tauri only)
|
|
||||||
*/
|
|
||||||
export async function setKeepServerRunning(keepRunning: boolean): Promise<void> {
|
|
||||||
if (!isTauri()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await invoke('set_keep_server_running', { keepRunning });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to set keep server running setting:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup window close handler to check setting and stop server if needed
|
|
||||||
*/
|
|
||||||
export async function setupWindowCloseHandler(): Promise<void> {
|
|
||||||
if (!isTauri()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Listen for window close request from Rust
|
|
||||||
await listen<null>('window-close-requested', async () => {
|
|
||||||
// Import store here to avoid circular dependency
|
|
||||||
const { useServerStore } = await import('@/stores/serverStore');
|
|
||||||
const keepRunning = useServerStore.getState().keepServerRunningOnClose;
|
|
||||||
|
|
||||||
// Check if server was started by this app instance
|
|
||||||
// In dev mode, serverStartedByApp will be false, so we won't try to stop a separately-run server
|
|
||||||
// We need to access the module-level variable - this is a bit hacky but works
|
|
||||||
// @ts-expect-error - accessing module-level variable from another module
|
|
||||||
const serverStartedByApp = window.__voiceboxServerStartedByApp ?? false;
|
|
||||||
|
|
||||||
if (!keepRunning && serverStartedByApp) {
|
|
||||||
// Stop server before closing (only if we started it)
|
|
||||||
try {
|
|
||||||
await stopServer();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to stop server on close:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit event back to Rust to allow close
|
|
||||||
await emit('window-close-allowed');
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to setup window close handler:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { createContext, useContext, type ReactNode } from 'react';
|
||||||
|
import type { Platform } from './types';
|
||||||
|
|
||||||
|
const PlatformContext = createContext<Platform | null>(null);
|
||||||
|
|
||||||
|
export interface PlatformProviderProps {
|
||||||
|
platform: Platform;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlatformProvider({ platform, children }: PlatformProviderProps) {
|
||||||
|
return (
|
||||||
|
<PlatformContext.Provider value={platform}>
|
||||||
|
{children}
|
||||||
|
</PlatformContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlatform(): Platform {
|
||||||
|
const platform = useContext(PlatformContext);
|
||||||
|
if (!platform) {
|
||||||
|
throw new Error('usePlatform must be used within PlatformProvider');
|
||||||
|
}
|
||||||
|
return platform;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Platform abstraction types
|
||||||
|
* These interfaces define the contract that platform implementations must fulfill
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FileFilter {
|
||||||
|
name: string;
|
||||||
|
extensions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformFilesystem {
|
||||||
|
saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateStatus {
|
||||||
|
checking: boolean;
|
||||||
|
available: boolean;
|
||||||
|
version?: string;
|
||||||
|
downloading: boolean;
|
||||||
|
installing: boolean;
|
||||||
|
readyToInstall: boolean;
|
||||||
|
error?: string;
|
||||||
|
downloadProgress?: number; // 0-100 percentage
|
||||||
|
downloadedBytes?: number;
|
||||||
|
totalBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformUpdater {
|
||||||
|
checkForUpdates(): Promise<void>;
|
||||||
|
downloadAndInstall(): Promise<void>;
|
||||||
|
restartAndInstall(): Promise<void>;
|
||||||
|
getStatus(): UpdateStatus;
|
||||||
|
subscribe(callback: (status: UpdateStatus) => void): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioDevice {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
is_default: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformAudio {
|
||||||
|
isSystemAudioSupported(): boolean;
|
||||||
|
startSystemAudioCapture(maxDurationSecs: number): Promise<void>;
|
||||||
|
stopSystemAudioCapture(): Promise<Blob>;
|
||||||
|
listOutputDevices(): Promise<AudioDevice[]>;
|
||||||
|
playToDevices(audioData: Uint8Array, deviceIds: string[]): Promise<void>;
|
||||||
|
stopPlayback(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformLifecycle {
|
||||||
|
startServer(remote?: boolean): Promise<string>;
|
||||||
|
stopServer(): Promise<void>;
|
||||||
|
setKeepServerRunning(keep: boolean): Promise<void>;
|
||||||
|
setupWindowCloseHandler(): Promise<void>;
|
||||||
|
onServerReady?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformMetadata {
|
||||||
|
getVersion(): Promise<string>;
|
||||||
|
isTauri: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Platform {
|
||||||
|
filesystem: PlatformFilesystem;
|
||||||
|
updater: PlatformUpdater;
|
||||||
|
audio: PlatformAudio;
|
||||||
|
lifecycle: PlatformLifecycle;
|
||||||
|
metadata: PlatformMetadata;
|
||||||
|
}
|
||||||
+2
-1
@@ -10,7 +10,8 @@ import { Toaster } from '@/components/ui/toaster';
|
|||||||
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
|
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
|
||||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||||
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
|
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
|
||||||
import { isMacOS } from '@/lib/tauri';
|
// Simple platform check that works in both web and Tauri
|
||||||
|
const isMacOS = () => navigator.platform.toLowerCase().includes('mac');
|
||||||
|
|
||||||
// Root layout component
|
// Root layout component
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
# Backend package
|
# Backend package
|
||||||
|
|
||||||
__version__ = "0.1.10"
|
__version__ = "0.1.11"
|
||||||
|
|||||||
@@ -175,7 +175,13 @@ class MLXTTSBackend:
|
|||||||
if cached_prompt is not None:
|
if cached_prompt is not None:
|
||||||
# Return cached prompt (should be dict format)
|
# Return cached prompt (should be dict format)
|
||||||
if isinstance(cached_prompt, dict):
|
if isinstance(cached_prompt, dict):
|
||||||
return cached_prompt, True
|
# Validate that the cached audio file still exists
|
||||||
|
cached_audio_path = cached_prompt.get("ref_audio") or cached_prompt.get("ref_audio_path")
|
||||||
|
if cached_audio_path and Path(cached_audio_path).exists():
|
||||||
|
return cached_prompt, True
|
||||||
|
else:
|
||||||
|
# Cached file no longer exists, invalidate cache
|
||||||
|
print(f"Cached audio file not found: {cached_audio_path}, regenerating prompt")
|
||||||
|
|
||||||
# MLX voice prompt format - store audio path and text
|
# MLX voice prompt format - store audio path and text
|
||||||
# The model will process this during generation
|
# The model will process this during generation
|
||||||
@@ -263,6 +269,13 @@ class MLXTTSBackend:
|
|||||||
ref_audio = voice_prompt.get("ref_audio") or voice_prompt.get("ref_audio_path")
|
ref_audio = voice_prompt.get("ref_audio") or voice_prompt.get("ref_audio_path")
|
||||||
ref_text = voice_prompt.get("ref_text", "")
|
ref_text = voice_prompt.get("ref_text", "")
|
||||||
|
|
||||||
|
# Validate that the audio file exists
|
||||||
|
if ref_audio and not Path(ref_audio).exists():
|
||||||
|
print(f"Warning: Audio file not found: {ref_audio}")
|
||||||
|
print("This may be due to a cached voice prompt referencing a deleted temp file.")
|
||||||
|
print("Regenerating without voice prompt.")
|
||||||
|
ref_audio = None
|
||||||
|
|
||||||
# Check if model supports voice cloning via generate method
|
# Check if model supports voice cloning via generate method
|
||||||
# MLX API may support ref_audio parameter directly
|
# MLX API may support ref_audio parameter directly
|
||||||
try:
|
try:
|
||||||
@@ -341,21 +354,34 @@ class MLXSTTBackend:
|
|||||||
def _load_model_sync(self, model_size: str):
|
def _load_model_sync(self, model_size: str):
|
||||||
"""Synchronous model loading."""
|
"""Synchronous model loading."""
|
||||||
try:
|
try:
|
||||||
from mlx_audio.asr import load
|
# IMPORTANT: Set up progress tracking BEFORE importing mlx_audio
|
||||||
|
# This ensures tqdm is patched before any HuggingFace Hub imports
|
||||||
# MLX Whisper model naming
|
|
||||||
model_name = f"mlx-community/whisper-{model_size}"
|
|
||||||
|
|
||||||
# Set up progress tracking
|
|
||||||
progress_manager = get_progress_manager()
|
progress_manager = get_progress_manager()
|
||||||
progress_model_name = f"whisper-{model_size}"
|
progress_model_name = f"whisper-{model_size}"
|
||||||
|
|
||||||
|
# Set up progress callback and tracker
|
||||||
|
progress_callback = create_hf_progress_callback(progress_model_name, progress_manager)
|
||||||
|
tracker = HFProgressTracker(progress_callback)
|
||||||
|
|
||||||
|
# Patch tqdm BEFORE importing mlx_audio
|
||||||
|
# This is critical because mlx_audio imports huggingface_hub which imports tqdm
|
||||||
|
print("[DEBUG] Starting tqdm patch BEFORE mlx_audio import")
|
||||||
|
tracker_context = tracker.patch_download()
|
||||||
|
tracker_context.__enter__()
|
||||||
|
print("[DEBUG] tqdm patched, now importing mlx_audio")
|
||||||
|
|
||||||
|
# NOW import mlx_audio - it will use our patched tqdm
|
||||||
|
from mlx_audio.stt import load
|
||||||
|
|
||||||
|
# MLX Whisper uses the standard OpenAI models
|
||||||
|
model_name = f"openai/whisper-{model_size}"
|
||||||
|
|
||||||
# Start tracking download task
|
# Start tracking download task
|
||||||
task_manager = get_task_manager()
|
task_manager = get_task_manager()
|
||||||
task_manager.start_download(progress_model_name)
|
task_manager.start_download(progress_model_name)
|
||||||
|
|
||||||
print(f"Loading MLX Whisper model {model_size}...")
|
print(f"Loading MLX Whisper model {model_size}...")
|
||||||
|
|
||||||
# Initialize progress state
|
# Initialize progress state
|
||||||
progress_manager.update_progress(
|
progress_manager.update_progress(
|
||||||
model_name=progress_model_name,
|
model_name=progress_model_name,
|
||||||
@@ -364,14 +390,13 @@ class MLXSTTBackend:
|
|||||||
filename="",
|
filename="",
|
||||||
status="downloading",
|
status="downloading",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set up progress callback
|
# Load the model (tqdm is already patched from above)
|
||||||
progress_callback = create_hf_progress_callback(progress_model_name, progress_manager)
|
try:
|
||||||
tracker = HFProgressTracker(progress_callback)
|
|
||||||
|
|
||||||
# Use progress tracker during download
|
|
||||||
with tracker.patch_download():
|
|
||||||
self.model = load(model_name)
|
self.model = load(model_name)
|
||||||
|
finally:
|
||||||
|
# Exit the patch context
|
||||||
|
tracker_context.__exit__(None, None, None)
|
||||||
|
|
||||||
self.model_size = model_size
|
self.model_size = model_size
|
||||||
|
|
||||||
@@ -412,34 +437,35 @@ class MLXSTTBackend:
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Transcribe audio to text.
|
Transcribe audio to text.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
audio_path: Path to audio file
|
audio_path: Path to audio file
|
||||||
language: Optional language hint (en or zh)
|
language: Optional language hint (en or zh)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Transcribed text
|
Transcribed text
|
||||||
"""
|
"""
|
||||||
await self.load_model_async(None)
|
await self.load_model_async(None)
|
||||||
|
|
||||||
def _transcribe_sync():
|
def _transcribe_sync():
|
||||||
"""Run synchronous transcription in thread pool."""
|
"""Run synchronous transcription in thread pool."""
|
||||||
# Load audio
|
# MLX Whisper transcription using generate method
|
||||||
audio, sr = load_audio(audio_path, sample_rate=16000)
|
# The generate method accepts audio path directly
|
||||||
|
decode_options = {}
|
||||||
# MLX Whisper transcription
|
if language:
|
||||||
# The API may vary - check mlx-audio documentation
|
decode_options["language"] = language
|
||||||
# For now, assuming similar API to PyTorch Whisper
|
|
||||||
result = self.model.transcribe(audio, language=language)
|
result = self.model.generate(str(audio_path), **decode_options)
|
||||||
|
|
||||||
# Extract text from result (format may vary)
|
# Extract text from result
|
||||||
if isinstance(result, str):
|
if isinstance(result, str):
|
||||||
return result.strip()
|
return result.strip()
|
||||||
elif isinstance(result, dict):
|
elif isinstance(result, dict):
|
||||||
return result.get("text", "").strip()
|
return result.get("text", "").strip()
|
||||||
|
elif hasattr(result, "text"):
|
||||||
|
return result.text.strip()
|
||||||
else:
|
else:
|
||||||
# Try to get text attribute
|
|
||||||
return str(result).strip()
|
return str(result).strip()
|
||||||
|
|
||||||
# Run blocking transcription in thread pool
|
# Run blocking transcription in thread pool
|
||||||
return await asyncio.to_thread(_transcribe_sync)
|
return await asyncio.to_thread(_transcribe_sync)
|
||||||
|
|||||||
@@ -85,21 +85,31 @@ class PyTorchTTSBackend:
|
|||||||
def _load_model_sync(self, model_size: str):
|
def _load_model_sync(self, model_size: str):
|
||||||
"""Synchronous model loading."""
|
"""Synchronous model loading."""
|
||||||
try:
|
try:
|
||||||
from qwen_tts import Qwen3TTSModel
|
# IMPORTANT: Set up progress tracking BEFORE importing qwen_tts
|
||||||
|
# This ensures tqdm is patched before any HuggingFace Hub imports
|
||||||
# Get model path (local or HuggingFace Hub ID)
|
|
||||||
model_path = self._get_model_path(model_size)
|
|
||||||
|
|
||||||
# Set up progress tracking
|
|
||||||
progress_manager = get_progress_manager()
|
progress_manager = get_progress_manager()
|
||||||
model_name = f"qwen-tts-{model_size}"
|
model_name = f"qwen-tts-{model_size}"
|
||||||
|
|
||||||
|
# Set up progress callback and tracker
|
||||||
|
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
||||||
|
tracker = HFProgressTracker(progress_callback)
|
||||||
|
|
||||||
|
# Patch tqdm BEFORE importing qwen_tts
|
||||||
|
tracker_context = tracker.patch_download()
|
||||||
|
tracker_context.__enter__()
|
||||||
|
|
||||||
|
# NOW import qwen_tts - it will use our patched tqdm
|
||||||
|
from qwen_tts import Qwen3TTSModel
|
||||||
|
|
||||||
|
# Get model path (local or HuggingFace Hub ID)
|
||||||
|
model_path = self._get_model_path(model_size)
|
||||||
|
|
||||||
print(f"Loading TTS model {model_size} on {self.device}...")
|
print(f"Loading TTS model {model_size} on {self.device}...")
|
||||||
|
|
||||||
# Start tracking download task
|
# Start tracking download task
|
||||||
task_manager = get_task_manager()
|
task_manager = get_task_manager()
|
||||||
task_manager.start_download(model_name)
|
task_manager.start_download(model_name)
|
||||||
|
|
||||||
# Initialize progress state to show download has started
|
# Initialize progress state to show download has started
|
||||||
progress_manager.update_progress(
|
progress_manager.update_progress(
|
||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
@@ -108,19 +118,17 @@ class PyTorchTTSBackend:
|
|||||||
filename="",
|
filename="",
|
||||||
status="downloading",
|
status="downloading",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set up progress callback
|
# Load the model (tqdm is already patched from above)
|
||||||
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
try:
|
||||||
tracker = HFProgressTracker(progress_callback)
|
|
||||||
|
|
||||||
# Use progress tracker during download
|
|
||||||
with tracker.patch_download():
|
|
||||||
# Load the model - downloads will happen automatically with progress tracking
|
|
||||||
self.model = Qwen3TTSModel.from_pretrained(
|
self.model = Qwen3TTSModel.from_pretrained(
|
||||||
model_path,
|
model_path,
|
||||||
device_map=self.device,
|
device_map=self.device,
|
||||||
torch_dtype=torch.float32 if self.device == "cpu" else torch.bfloat16,
|
torch_dtype=torch.float32 if self.device == "cpu" else torch.bfloat16,
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
|
# Exit the patch context
|
||||||
|
tracker_context.__exit__(None, None, None)
|
||||||
|
|
||||||
# Mark as complete
|
# Mark as complete
|
||||||
progress_manager.mark_complete(model_name)
|
progress_manager.mark_complete(model_name)
|
||||||
@@ -188,6 +196,8 @@ class PyTorchTTSBackend:
|
|||||||
# Cache stores as torch.Tensor but actual prompt is dict
|
# Cache stores as torch.Tensor but actual prompt is dict
|
||||||
# Convert if needed
|
# Convert if needed
|
||||||
if isinstance(cached_prompt, dict):
|
if isinstance(cached_prompt, dict):
|
||||||
|
# For PyTorch backend, the dict should contain tensors, not file paths
|
||||||
|
# So we can safely return it
|
||||||
return cached_prompt, True
|
return cached_prompt, True
|
||||||
elif isinstance(cached_prompt, torch.Tensor):
|
elif isinstance(cached_prompt, torch.Tensor):
|
||||||
# Legacy cache format - convert to dict
|
# Legacy cache format - convert to dict
|
||||||
@@ -314,40 +324,61 @@ class PyTorchSTTBackend:
|
|||||||
async def load_model_async(self, model_size: Optional[str] = None):
|
async def load_model_async(self, model_size: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Lazy load the Whisper model.
|
Lazy load the Whisper model.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
model_size: Model size (tiny, base, small, medium, large)
|
model_size: Model size (tiny, base, small, medium, large)
|
||||||
"""
|
"""
|
||||||
|
print(f"[DEBUG] load_model_async called with size: {model_size}")
|
||||||
if model_size is None:
|
if model_size is None:
|
||||||
model_size = self.model_size
|
model_size = self.model_size
|
||||||
|
|
||||||
|
print(f"[DEBUG] Model already loaded? {self.model is not None}, current size: {self.model_size}, requested: {model_size}")
|
||||||
if self.model is not None and self.model_size == model_size:
|
if self.model is not None and self.model_size == model_size:
|
||||||
|
print(f"[DEBUG] Early return - model already loaded")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
print(f"[DEBUG] Calling asyncio.to_thread for _load_model_sync")
|
||||||
# Run blocking load in thread pool
|
# Run blocking load in thread pool
|
||||||
await asyncio.to_thread(self._load_model_sync, model_size)
|
await asyncio.to_thread(self._load_model_sync, model_size)
|
||||||
|
print(f"[DEBUG] asyncio.to_thread completed")
|
||||||
|
|
||||||
# Alias for compatibility
|
# Alias for compatibility
|
||||||
load_model = load_model_async
|
load_model = load_model_async
|
||||||
|
|
||||||
def _load_model_sync(self, model_size: str):
|
def _load_model_sync(self, model_size: str):
|
||||||
"""Synchronous model loading."""
|
"""Synchronous model loading."""
|
||||||
|
print(f"[DEBUG] _load_model_sync called for Whisper {model_size}")
|
||||||
try:
|
try:
|
||||||
from transformers import WhisperProcessor, WhisperForConditionalGeneration
|
# IMPORTANT: Set up progress tracking BEFORE importing transformers
|
||||||
|
# This ensures tqdm is patched before any HuggingFace Hub imports
|
||||||
model_name = f"openai/whisper-{model_size}"
|
|
||||||
|
|
||||||
# Set up progress tracking
|
|
||||||
progress_manager = get_progress_manager()
|
progress_manager = get_progress_manager()
|
||||||
progress_model_name = f"whisper-{model_size}"
|
progress_model_name = f"whisper-{model_size}"
|
||||||
|
|
||||||
|
# Set up progress callback and tracker
|
||||||
|
progress_callback = create_hf_progress_callback(progress_model_name, progress_manager)
|
||||||
|
tracker = HFProgressTracker(progress_callback)
|
||||||
|
|
||||||
|
# Patch tqdm BEFORE importing transformers
|
||||||
|
print("[DEBUG] Starting tqdm patch BEFORE transformers import")
|
||||||
|
tracker_context = tracker.patch_download()
|
||||||
|
tracker_context.__enter__()
|
||||||
|
print("[DEBUG] tqdm patched, now importing transformers")
|
||||||
|
|
||||||
|
# NOW import transformers - it will use our patched tqdm
|
||||||
|
from transformers import WhisperProcessor, WhisperForConditionalGeneration
|
||||||
|
|
||||||
|
model_name = f"openai/whisper-{model_size}"
|
||||||
|
print(f"[DEBUG] Model name: {model_name}")
|
||||||
|
|
||||||
# Start tracking download task
|
# Start tracking download task
|
||||||
task_manager = get_task_manager()
|
task_manager = get_task_manager()
|
||||||
task_manager.start_download(progress_model_name)
|
task_manager.start_download(progress_model_name)
|
||||||
|
print(f"[DEBUG] Task manager started download")
|
||||||
|
|
||||||
print(f"Loading Whisper model {model_size} on {self.device}...")
|
print(f"Loading Whisper model {model_size} on {self.device}...")
|
||||||
|
|
||||||
# Initialize progress state to show download has started
|
# Initialize progress state to show download has started
|
||||||
|
print(f"[DEBUG] Calling update_progress...")
|
||||||
progress_manager.update_progress(
|
progress_manager.update_progress(
|
||||||
model_name=progress_model_name,
|
model_name=progress_model_name,
|
||||||
current=0,
|
current=0,
|
||||||
@@ -355,15 +386,15 @@ class PyTorchSTTBackend:
|
|||||||
filename="",
|
filename="",
|
||||||
status="downloading",
|
status="downloading",
|
||||||
)
|
)
|
||||||
|
print(f"[DEBUG] update_progress called, listeners: {len(progress_manager._listeners.get(progress_model_name, []))}")
|
||||||
# Set up progress callback
|
|
||||||
progress_callback = create_hf_progress_callback(progress_model_name, progress_manager)
|
# Load models (tqdm is already patched from above)
|
||||||
tracker = HFProgressTracker(progress_callback)
|
try:
|
||||||
|
|
||||||
# Use progress tracker during download
|
|
||||||
with tracker.patch_download():
|
|
||||||
self.processor = WhisperProcessor.from_pretrained(model_name)
|
self.processor = WhisperProcessor.from_pretrained(model_name)
|
||||||
self.model = WhisperForConditionalGeneration.from_pretrained(model_name)
|
self.model = WhisperForConditionalGeneration.from_pretrained(model_name)
|
||||||
|
finally:
|
||||||
|
# Exit the patch context
|
||||||
|
tracker_context.__exit__(None, None, None)
|
||||||
|
|
||||||
self.model.to(self.device)
|
self.model.to(self.device)
|
||||||
self.model_size = model_size
|
self.model_size = model_size
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ def build_server():
|
|||||||
'--hidden-import', 'mlx.nn',
|
'--hidden-import', 'mlx.nn',
|
||||||
'--hidden-import', 'mlx_audio',
|
'--hidden-import', 'mlx_audio',
|
||||||
'--hidden-import', 'mlx_audio.tts',
|
'--hidden-import', 'mlx_audio.tts',
|
||||||
'--hidden-import', 'mlx_audio.asr',
|
'--hidden-import', 'mlx_audio.stt',
|
||||||
'--collect-submodules', 'mlx',
|
'--collect-submodules', 'mlx',
|
||||||
'--collect-submodules', 'mlx_audio',
|
'--collect-submodules', 'mlx_audio',
|
||||||
# Collect MLX data files including Metal shader libraries (.metallib)
|
# Collect MLX data files including Metal shader libraries (.metallib)
|
||||||
|
|||||||
+19
-1
@@ -27,6 +27,7 @@ from . import database, models, profiles, history, tts, transcribe, config, expo
|
|||||||
from .database import get_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
|
||||||
from .utils.tasks import get_task_manager
|
from .utils.tasks import get_task_manager
|
||||||
|
from .utils.cache import clear_voice_prompt_cache
|
||||||
from .platform_detect import get_backend_type
|
from .platform_detect import get_backend_type
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -1393,7 +1394,11 @@ async def trigger_model_download(request: models.ModelDownloadRequest):
|
|||||||
async def download_in_background():
|
async def download_in_background():
|
||||||
"""Download model in background without blocking the HTTP request."""
|
"""Download model in background without blocking the HTTP request."""
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(config["load_func"])
|
# Call the load function (which may be async)
|
||||||
|
result = config["load_func"]()
|
||||||
|
# If it's a coroutine, await it
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
await result
|
||||||
task_manager.complete_download(request.model_name)
|
task_manager.complete_download(request.model_name)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
task_manager.error_download(request.model_name, str(e))
|
task_manager.error_download(request.model_name, str(e))
|
||||||
@@ -1491,6 +1496,19 @@ async def delete_model(model_name: str):
|
|||||||
raise HTTPException(status_code=500, detail=f"Failed to delete model: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to delete model: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/cache/clear")
|
||||||
|
async def clear_cache():
|
||||||
|
"""Clear all voice prompt caches (memory and disk)."""
|
||||||
|
try:
|
||||||
|
deleted_count = clear_voice_prompt_cache()
|
||||||
|
return {
|
||||||
|
"message": f"Voice prompt cache cleared successfully",
|
||||||
|
"files_deleted": deleted_count,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to clear cache: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# TASK MANAGEMENT
|
# TASK MANAGEMENT
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
+42
-16
@@ -22,6 +22,7 @@ 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 .utils.images import validate_image, process_avatar
|
from .utils.images import validate_image, process_avatar
|
||||||
|
from .utils.cache import _get_cache_dir, clear_profile_cache
|
||||||
from .tts import get_tts_model
|
from .tts import get_tts_model
|
||||||
from . import config
|
from . import config
|
||||||
|
|
||||||
@@ -120,6 +121,10 @@ async def add_profile_sample(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_sample)
|
db.refresh(db_sample)
|
||||||
|
|
||||||
|
# Invalidate combined audio cache for this profile
|
||||||
|
# Since a new sample was added, any cached combined audio is now stale
|
||||||
|
clear_profile_cache(profile_id)
|
||||||
|
|
||||||
return ProfileSampleResponse.model_validate(db_sample)
|
return ProfileSampleResponse.model_validate(db_sample)
|
||||||
|
|
||||||
|
|
||||||
@@ -241,6 +246,9 @@ async def delete_profile(
|
|||||||
if profile_dir.exists():
|
if profile_dir.exists():
|
||||||
shutil.rmtree(profile_dir)
|
shutil.rmtree(profile_dir)
|
||||||
|
|
||||||
|
# Clean up combined audio cache files for this profile
|
||||||
|
clear_profile_cache(profile_id)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -262,6 +270,9 @@ async def delete_profile_sample(
|
|||||||
if not sample:
|
if not sample:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Store profile_id before deleting
|
||||||
|
profile_id = sample.profile_id
|
||||||
|
|
||||||
# Delete audio file
|
# Delete audio file
|
||||||
audio_path = Path(sample.audio_path)
|
audio_path = Path(sample.audio_path)
|
||||||
if audio_path.exists():
|
if audio_path.exists():
|
||||||
@@ -271,6 +282,10 @@ async def delete_profile_sample(
|
|||||||
db.delete(sample)
|
db.delete(sample)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
# Invalidate combined audio cache for this profile
|
||||||
|
# Since the sample set changed, any cached combined audio is now stale
|
||||||
|
clear_profile_cache(profile_id)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -294,10 +309,17 @@ async def update_profile_sample(
|
|||||||
if not sample:
|
if not sample:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Store profile_id before updating
|
||||||
|
profile_id = sample.profile_id
|
||||||
|
|
||||||
sample.reference_text = reference_text
|
sample.reference_text = reference_text
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(sample)
|
db.refresh(sample)
|
||||||
|
|
||||||
|
# Invalidate combined audio cache for this profile
|
||||||
|
# Since the reference text changed, cache keys and combined text are now stale
|
||||||
|
clear_profile_cache(profile_id)
|
||||||
|
|
||||||
return ProfileSampleResponse.model_validate(sample)
|
return ProfileSampleResponse.model_validate(sample)
|
||||||
|
|
||||||
|
|
||||||
@@ -345,23 +367,27 @@ async def create_voice_prompt_for_profile(
|
|||||||
reference_texts,
|
reference_texts,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save combined audio temporarily
|
# Save combined audio to cache directory (persistent)
|
||||||
import tempfile
|
# Create a hash of sample IDs to identify this specific combination
|
||||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
import hashlib
|
||||||
save_audio(combined_audio, tmp.name, 24000)
|
sample_ids_str = "-".join(sorted([s.id for s in samples]))
|
||||||
tmp_path = tmp.name
|
combination_hash = hashlib.md5(sample_ids_str.encode()).hexdigest()[:12]
|
||||||
|
|
||||||
|
# Store in cache directory
|
||||||
|
cache_dir = _get_cache_dir()
|
||||||
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
combined_path = cache_dir / f"combined_{profile_id}_{combination_hash}.wav"
|
||||||
|
|
||||||
|
# Save combined audio
|
||||||
|
save_audio(combined_audio, str(combined_path), 24000)
|
||||||
|
|
||||||
try:
|
# Create prompt from combined audio
|
||||||
# Create prompt from combined audio
|
voice_prompt, _ = await tts_model.create_voice_prompt(
|
||||||
voice_prompt, _ = await tts_model.create_voice_prompt(
|
str(combined_path),
|
||||||
tmp_path,
|
combined_text,
|
||||||
combined_text,
|
use_cache=use_cache,
|
||||||
use_cache=use_cache,
|
)
|
||||||
)
|
return voice_prompt
|
||||||
return voice_prompt
|
|
||||||
finally:
|
|
||||||
# Clean up temp file
|
|
||||||
Path(tmp_path).unlink(missing_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
async def upload_avatar(
|
async def upload_avatar(
|
||||||
|
|||||||
@@ -88,3 +88,63 @@ def cache_voice_prompt(
|
|||||||
# Store on disk (torch.save can handle both dicts and tensors)
|
# Store on disk (torch.save can handle both dicts and tensors)
|
||||||
cache_file = _get_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)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_voice_prompt_cache() -> int:
|
||||||
|
"""
|
||||||
|
Clear all voice prompt caches (memory and disk).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of cache files deleted
|
||||||
|
"""
|
||||||
|
# Clear memory cache
|
||||||
|
_memory_cache.clear()
|
||||||
|
|
||||||
|
# Clear disk cache
|
||||||
|
cache_dir = _get_cache_dir()
|
||||||
|
deleted_count = 0
|
||||||
|
|
||||||
|
if cache_dir.exists():
|
||||||
|
# Delete prompt cache files
|
||||||
|
for cache_file in cache_dir.glob("*.prompt"):
|
||||||
|
try:
|
||||||
|
cache_file.unlink()
|
||||||
|
deleted_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to delete cache file {cache_file}: {e}")
|
||||||
|
|
||||||
|
# Delete combined audio files
|
||||||
|
for audio_file in cache_dir.glob("combined_*.wav"):
|
||||||
|
try:
|
||||||
|
audio_file.unlink()
|
||||||
|
deleted_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to delete combined audio file {audio_file}: {e}")
|
||||||
|
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
|
|
||||||
|
def clear_profile_cache(profile_id: str) -> int:
|
||||||
|
"""
|
||||||
|
Clear cache files for a specific profile.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile_id: Profile ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of cache files deleted
|
||||||
|
"""
|
||||||
|
cache_dir = _get_cache_dir()
|
||||||
|
deleted_count = 0
|
||||||
|
|
||||||
|
if cache_dir.exists():
|
||||||
|
# Delete combined audio files for this profile
|
||||||
|
pattern = f"combined_{profile_id}_*.wav"
|
||||||
|
for audio_file in cache_dir.glob(pattern):
|
||||||
|
try:
|
||||||
|
audio_file.unlink()
|
||||||
|
deleted_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to delete combined audio file {audio_file}: {e}")
|
||||||
|
|
||||||
|
return deleted_count
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ class HFProgressTracker:
|
|||||||
|
|
||||||
class TrackedTqdm(original_tqdm):
|
class TrackedTqdm(original_tqdm):
|
||||||
"""A tqdm subclass that reports progress to our tracker."""
|
"""A tqdm subclass that reports progress to our tracker."""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
print(f"[DEBUG TrackedTqdm] __init__ called with desc: {kwargs.get('desc', '')}")
|
||||||
# Extract filename from desc before passing to parent
|
# Extract filename from desc before passing to parent
|
||||||
desc = kwargs.get("desc", "")
|
desc = kwargs.get("desc", "")
|
||||||
if not desc and args:
|
if not desc and args:
|
||||||
@@ -79,8 +80,9 @@ class HFProgressTracker:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def update(self, n=1):
|
def update(self, n=1):
|
||||||
|
print(f"[DEBUG TrackedTqdm] update called with n={n}")
|
||||||
result = super().update(n)
|
result = super().update(n)
|
||||||
|
|
||||||
# Report progress
|
# Report progress
|
||||||
with tracker._lock:
|
with tracker._lock:
|
||||||
if id(self) in tracker._active_tqdms:
|
if id(self) in tracker._active_tqdms:
|
||||||
@@ -118,11 +120,13 @@ class HFProgressTracker:
|
|||||||
@contextmanager
|
@contextmanager
|
||||||
def patch_download(self):
|
def patch_download(self):
|
||||||
"""Context manager to patch tqdm for progress tracking."""
|
"""Context manager to patch tqdm for progress tracking."""
|
||||||
|
print("[DEBUG HFProgressTracker] patch_download called")
|
||||||
try:
|
try:
|
||||||
import tqdm as tqdm_module
|
import tqdm as tqdm_module
|
||||||
|
|
||||||
# Store original tqdm class
|
# Store original tqdm class
|
||||||
self._original_tqdm_class = tqdm_module.tqdm
|
self._original_tqdm_class = tqdm_module.tqdm
|
||||||
|
print(f"[DEBUG HFProgressTracker] Original tqdm class: {self._original_tqdm_class}")
|
||||||
|
|
||||||
# Reset totals
|
# Reset totals
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -135,18 +139,22 @@ class HFProgressTracker:
|
|||||||
|
|
||||||
# Create our tracked tqdm class
|
# Create our tracked tqdm class
|
||||||
tracked_tqdm = self._create_tracked_tqdm_class()
|
tracked_tqdm = self._create_tracked_tqdm_class()
|
||||||
|
print(f"[DEBUG HFProgressTracker] Created TrackedTqdm class: {tracked_tqdm}")
|
||||||
|
|
||||||
# Patch tqdm.tqdm
|
# Patch tqdm.tqdm
|
||||||
tqdm_module.tqdm = tracked_tqdm
|
tqdm_module.tqdm = tracked_tqdm
|
||||||
|
print(f"[DEBUG HFProgressTracker] Patched tqdm.tqdm")
|
||||||
|
|
||||||
# Also patch tqdm.auto.tqdm if it exists (used by huggingface_hub)
|
# Also patch tqdm.auto.tqdm if it exists (used by huggingface_hub)
|
||||||
self._original_tqdm_auto = None
|
self._original_tqdm_auto = None
|
||||||
if hasattr(tqdm_module, "auto") and hasattr(tqdm_module.auto, "tqdm"):
|
if hasattr(tqdm_module, "auto") and hasattr(tqdm_module.auto, "tqdm"):
|
||||||
self._original_tqdm_auto = tqdm_module.auto.tqdm
|
self._original_tqdm_auto = tqdm_module.auto.tqdm
|
||||||
tqdm_module.auto.tqdm = tracked_tqdm
|
tqdm_module.auto.tqdm = tracked_tqdm
|
||||||
|
print(f"[DEBUG HFProgressTracker] Patched tqdm.auto.tqdm")
|
||||||
|
|
||||||
# Patch in sys.modules to catch already-imported references
|
# Patch in sys.modules to catch already-imported references
|
||||||
self._patched_modules = {}
|
self._patched_modules = {}
|
||||||
|
patched_count = 0
|
||||||
for module_name in list(sys.modules.keys()):
|
for module_name in list(sys.modules.keys()):
|
||||||
if "huggingface" in module_name or module_name.startswith("tqdm"):
|
if "huggingface" in module_name or module_name.startswith("tqdm"):
|
||||||
try:
|
try:
|
||||||
@@ -159,8 +167,11 @@ class HFProgressTracker:
|
|||||||
):
|
):
|
||||||
self._patched_modules[module_name] = attr
|
self._patched_modules[module_name] = attr
|
||||||
setattr(module, "tqdm", tracked_tqdm)
|
setattr(module, "tqdm", tracked_tqdm)
|
||||||
|
patched_count += 1
|
||||||
|
print(f"[DEBUG HFProgressTracker] Patched {module_name}.tqdm")
|
||||||
except (AttributeError, TypeError):
|
except (AttributeError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
print(f"[DEBUG HFProgressTracker] Patched {patched_count} modules in sys.modules")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class ProgressManager:
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Update progress for a model download.
|
Update progress for a model download.
|
||||||
|
|
||||||
Thread-safe: can be called from background threads.
|
Thread-safe: can be called from background threads.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -89,16 +89,26 @@ class ProgressManager:
|
|||||||
"status": status,
|
"status": status,
|
||||||
"timestamp": datetime.now().isoformat(),
|
"timestamp": datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
print(f"[DEBUG] update_progress called: {model_name}, {progress_pct:.1f}%")
|
||||||
|
|
||||||
# Thread-safe update of progress dict
|
# Thread-safe update of progress dict
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._progress[model_name] = progress_data
|
self._progress[model_name] = progress_data
|
||||||
|
|
||||||
# Notify all listeners (thread-safe)
|
# Notify all listeners (thread-safe)
|
||||||
listener_count = len(self._listeners.get(model_name, []))
|
listener_count = len(self._listeners.get(model_name, []))
|
||||||
|
print(f"[DEBUG] Listener count for {model_name}: {listener_count}")
|
||||||
|
print(f"[DEBUG] All listeners: {list(self._listeners.keys())}")
|
||||||
|
print(f"[DEBUG] Main loop set: {self._main_loop is not None}")
|
||||||
|
if self._main_loop:
|
||||||
|
print(f"[DEBUG] Main loop running: {self._main_loop.is_running()}")
|
||||||
|
|
||||||
if listener_count > 0:
|
if listener_count > 0:
|
||||||
logger.debug(f"Notifying {listener_count} listeners for {model_name}: {progress_pct:.1f}% ({filename})")
|
logger.debug(f"Notifying {listener_count} listeners for {model_name}: {progress_pct:.1f}% ({filename})")
|
||||||
|
print(f"[DEBUG] About to notify listeners...")
|
||||||
self._notify_listeners_threadsafe(model_name, progress_data)
|
self._notify_listeners_threadsafe(model_name, progress_data)
|
||||||
|
print(f"[DEBUG] Notified listeners")
|
||||||
else:
|
else:
|
||||||
logger.debug(f"No listeners for {model_name}, progress update stored: {progress_pct:.1f}%")
|
logger.debug(f"No listeners for {model_name}, progress update stored: {progress_pct:.1f}%")
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from PyInstaller.utils.hooks import collect_submodules
|
|||||||
from PyInstaller.utils.hooks import copy_metadata
|
from PyInstaller.utils.hooks import copy_metadata
|
||||||
|
|
||||||
datas = []
|
datas = []
|
||||||
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', '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', 'pkg_resources.extern', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.asr']
|
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', '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', 'pkg_resources.extern', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
|
||||||
datas += collect_data_files('qwen_tts')
|
datas += collect_data_files('qwen_tts')
|
||||||
datas += collect_data_files('mlx')
|
datas += collect_data_files('mlx')
|
||||||
datas += collect_data_files('mlx_audio')
|
datas += collect_data_files('mlx_audio')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/landing",
|
"name": "@voicebox/landing",
|
||||||
"version": "0.1.10",
|
"version": "0.1.11",
|
||||||
"description": "Landing page for voicebox.sh",
|
"description": "Landing page for voicebox.sh",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun --bun next dev --turbo",
|
"dev": "bun --bun next dev --turbo",
|
||||||
|
|||||||
+12
-10
@@ -5,7 +5,7 @@ import Image from 'next/image';
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { AppleIcon, LinuxIcon, WindowsIcon } from '@/components/PlatformIcons';
|
import { AppleIcon, LinuxIcon, WindowsIcon } from '@/components/PlatformIcons';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Section, SectionTitle } from '@/components/ui/section';
|
import { Section } from '@/components/ui/section';
|
||||||
import { DOWNLOAD_LINKS, GITHUB_REPO } from '@/lib/constants';
|
import { DOWNLOAD_LINKS, GITHUB_REPO } from '@/lib/constants';
|
||||||
import type { DownloadLinks } from '@/lib/releases';
|
import type { DownloadLinks } from '@/lib/releases';
|
||||||
import { FeatureCard } from '../components/ui/feature-card';
|
import { FeatureCard } from '../components/ui/feature-card';
|
||||||
@@ -39,17 +39,19 @@ export default function Home() {
|
|||||||
"Powered by Alibaba's Qwen3-TTS model for exceptional voice quality and accuracy.",
|
"Powered by Alibaba's Qwen3-TTS model for exceptional voice quality and accuracy.",
|
||||||
icon: <Zap className="h-6 w-6" />,
|
icon: <Zap className="h-6 w-6" />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Stories Editor',
|
||||||
|
description:
|
||||||
|
'Create multi-voice narratives with a timeline-based editor. Arrange tracks, trim clips, and mix conversations.',
|
||||||
|
icon: <Code className="h-6 w-6" />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Multi-Sample Support',
|
title: 'Multi-Sample Support',
|
||||||
description:
|
description:
|
||||||
'Combine multiple voice samples for higher quality and more natural-sounding results.',
|
'Combine multiple voice samples for higher quality and more natural-sounding results.',
|
||||||
icon: <Code className="h-6 w-6" />,
|
icon: <Code className="h-6 w-6" />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: 'Smart Caching',
|
|
||||||
description: 'Instant re-generation with voice prompt caching. No need to reprocess samples.',
|
|
||||||
icon: <Zap className="h-6 w-6" />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: 'Local or Remote',
|
title: 'Local or Remote',
|
||||||
description:
|
description:
|
||||||
@@ -246,6 +248,10 @@ export default function Home() {
|
|||||||
model, clone any voice from a few seconds of audio, and compose multi-voice projects
|
model, clone any voice from a few seconds of audio, and compose multi-voice projects
|
||||||
with studio-grade editing tools.
|
with studio-grade editing tools.
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
Optimized for performance with <strong>Metal acceleration on Mac</strong> and{' '}
|
||||||
|
<strong>CUDA acceleration on Windows/Linux</strong> for fast, local inference.
|
||||||
|
</p>
|
||||||
<p className="text-foreground/60">No Python install required.</p>
|
<p className="text-foreground/60">No Python install required.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,10 +287,6 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* Features Section */}
|
{/* Features Section */}
|
||||||
<Section id="features">
|
<Section id="features">
|
||||||
<SectionTitle className="mb-4 text-center">Features</SectionTitle>
|
|
||||||
<p className="text-sm text-muted-foreground mb-8 text-center max-w-2xl mx-auto">
|
|
||||||
Everything you need for professional voice cloning in a desktop app.
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||||
{features.map((feature) => (
|
{features.map((feature) => (
|
||||||
<FeatureCard
|
<FeatureCard
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "voicebox",
|
"name": "voicebox",
|
||||||
"version": "0.1.10",
|
"version": "0.1.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"app",
|
"app",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/tauri",
|
"name": "@voicebox/tauri",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.10",
|
"version": "0.1.11",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Generated
+1
-1
@@ -5041,7 +5041,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "voicebox"
|
name = "voicebox"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "voicebox"
|
name = "voicebox"
|
||||||
version = "0.1.10"
|
version = "0.1.11"
|
||||||
description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation"
|
description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
license = ""
|
license = ""
|
||||||
|
|||||||
@@ -10,5 +10,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.device.audio-input</key>
|
<key>com.apple.security.device.audio-input</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.files.user-selected.read-write</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Voicebox",
|
"productName": "Voicebox",
|
||||||
"version": "0.1.10",
|
"version": "0.1.11",
|
||||||
"identifier": "sh.voicebox.app",
|
"identifier": "sh.voicebox.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|||||||
+6
-2
@@ -5,6 +5,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|||||||
import App from '@/App';
|
import App from '@/App';
|
||||||
// Import CSS from app directory using alias so Tailwind can scan the source files
|
// Import CSS from app directory using alias so Tailwind can scan the source files
|
||||||
import '@/index.css';
|
import '@/index.css';
|
||||||
|
import { PlatformProvider } from '@/platform/PlatformContext';
|
||||||
|
import { tauriPlatform } from './platform';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -20,8 +22,10 @@ const queryClient = new QueryClient({
|
|||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<PlatformProvider platform={tauriPlatform}>
|
||||||
{/* <ReactQueryDevtools initialIsOpen={false} /> */}
|
<App />
|
||||||
|
{/* <ReactQueryDevtools initialIsOpen={false} /> */}
|
||||||
|
</PlatformProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import type { PlatformAudio, AudioDevice } from '@/platform/types';
|
||||||
|
|
||||||
|
export const tauriAudio: PlatformAudio = {
|
||||||
|
isSystemAudioSupported(): boolean {
|
||||||
|
// This will be checked dynamically via invoke
|
||||||
|
return true; // Tauri supports it, but actual support depends on platform
|
||||||
|
},
|
||||||
|
|
||||||
|
async startSystemAudioCapture(maxDurationSecs: number): Promise<void> {
|
||||||
|
await invoke('start_system_audio_capture', {
|
||||||
|
maxDurationSecs,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async stopSystemAudioCapture(): Promise<Blob> {
|
||||||
|
const base64Data = await invoke<string>('stop_system_audio_capture');
|
||||||
|
|
||||||
|
// Convert base64 to Blob
|
||||||
|
const binaryString = atob(base64Data);
|
||||||
|
const bytes = new Uint8Array(binaryString.length);
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Blob([bytes], { type: 'audio/wav' });
|
||||||
|
},
|
||||||
|
|
||||||
|
async listOutputDevices(): Promise<AudioDevice[]> {
|
||||||
|
return await invoke<AudioDevice[]>('list_audio_output_devices');
|
||||||
|
},
|
||||||
|
|
||||||
|
async playToDevices(audioData: Uint8Array, deviceIds: string[]): Promise<void> {
|
||||||
|
await invoke('play_audio_to_devices', {
|
||||||
|
audioData: Array.from(audioData),
|
||||||
|
deviceIds,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
stopPlayback(): void {
|
||||||
|
invoke('stop_audio_playback').catch((error) => {
|
||||||
|
console.error('Failed to stop audio playback:', error);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { PlatformFilesystem, FileFilter } from '@/platform/types';
|
||||||
|
|
||||||
|
export const tauriFilesystem: PlatformFilesystem = {
|
||||||
|
async saveFile(filename: string, blob: Blob, filters?: FileFilter[]) {
|
||||||
|
try {
|
||||||
|
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||||
|
const filePath = await save({
|
||||||
|
defaultPath: filename,
|
||||||
|
filters: filters || [],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filePath) {
|
||||||
|
const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
|
||||||
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
|
await writeBinaryFile(filePath, new Uint8Array(arrayBuffer));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to use Tauri dialog, falling back to browser download:', error);
|
||||||
|
// Fall back to browser download if Tauri dialog fails
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Platform } from '@/platform/types';
|
||||||
|
import { tauriFilesystem } from './filesystem';
|
||||||
|
import { tauriUpdater } from './updater';
|
||||||
|
import { tauriAudio } from './audio';
|
||||||
|
import { tauriLifecycle } from './lifecycle';
|
||||||
|
import { tauriMetadata } from './metadata';
|
||||||
|
|
||||||
|
export const tauriPlatform: Platform = {
|
||||||
|
filesystem: tauriFilesystem,
|
||||||
|
updater: tauriUpdater,
|
||||||
|
audio: tauriAudio,
|
||||||
|
lifecycle: tauriLifecycle,
|
||||||
|
metadata: tauriMetadata,
|
||||||
|
};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen, emit } from '@tauri-apps/api/event';
|
||||||
|
import type { PlatformLifecycle } from '@/platform/types';
|
||||||
|
|
||||||
|
class TauriLifecycle implements PlatformLifecycle {
|
||||||
|
onServerReady?: () => void;
|
||||||
|
|
||||||
|
async startServer(remote = false): Promise<string> {
|
||||||
|
try {
|
||||||
|
const result = await invoke<string>('start_server', { remote });
|
||||||
|
console.log('Server started:', result);
|
||||||
|
this.onServerReady?.();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start server:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopServer(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await invoke('stop_server');
|
||||||
|
console.log('Server stopped');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to stop server:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setKeepServerRunning(keepRunning: boolean): Promise<void> {
|
||||||
|
try {
|
||||||
|
await invoke('set_keep_server_running', { keepRunning });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to set keep server running setting:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setupWindowCloseHandler(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Listen for window close request from Rust
|
||||||
|
await listen<null>('window-close-requested', async () => {
|
||||||
|
// Import store here to avoid circular dependency
|
||||||
|
const { useServerStore } = await import('@/stores/serverStore');
|
||||||
|
const keepRunning = useServerStore.getState().keepServerRunningOnClose;
|
||||||
|
|
||||||
|
// Check if server was started by this app instance
|
||||||
|
// @ts-expect-error - accessing module-level variable from another module
|
||||||
|
const serverStartedByApp = window.__voiceboxServerStartedByApp ?? false;
|
||||||
|
|
||||||
|
if (!keepRunning && serverStartedByApp) {
|
||||||
|
// Stop server before closing (only if we started it)
|
||||||
|
try {
|
||||||
|
await this.stopServer();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to stop server on close:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit event back to Rust to allow close
|
||||||
|
await emit('window-close-allowed');
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to setup window close handler:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tauriLifecycle = new TauriLifecycle();
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
|
import type { PlatformMetadata } from '@/platform/types';
|
||||||
|
|
||||||
|
export const tauriMetadata: PlatformMetadata = {
|
||||||
|
async getVersion(): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await getVersion();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get version:', error);
|
||||||
|
return '0.1.0';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isTauri: true,
|
||||||
|
};
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { relaunch } from '@tauri-apps/plugin-process';
|
||||||
|
import { check, type Update } from '@tauri-apps/plugin-updater';
|
||||||
|
import type { PlatformUpdater, UpdateStatus } from '@/platform/types';
|
||||||
|
|
||||||
|
// Check if we're on Windows (NSIS installer handles restart automatically)
|
||||||
|
const isWindows = () => {
|
||||||
|
return navigator.userAgent.includes('Windows');
|
||||||
|
};
|
||||||
|
|
||||||
|
class TauriUpdater implements PlatformUpdater {
|
||||||
|
private status: UpdateStatus = {
|
||||||
|
checking: false,
|
||||||
|
available: false,
|
||||||
|
downloading: false,
|
||||||
|
installing: false,
|
||||||
|
readyToInstall: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
private update: Update | null = null;
|
||||||
|
private subscribers: Set<(status: UpdateStatus) => void> = new Set();
|
||||||
|
|
||||||
|
private notifySubscribers() {
|
||||||
|
this.subscribers.forEach((callback) => callback(this.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(callback: (status: UpdateStatus) => void): () => void {
|
||||||
|
this.subscribers.add(callback);
|
||||||
|
// Immediately call with current status
|
||||||
|
callback(this.status);
|
||||||
|
return () => {
|
||||||
|
this.subscribers.delete(callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus(): UpdateStatus {
|
||||||
|
return { ...this.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkForUpdates(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.status = { ...this.status, checking: true, error: undefined };
|
||||||
|
this.notifySubscribers();
|
||||||
|
|
||||||
|
const foundUpdate = await check();
|
||||||
|
|
||||||
|
if (foundUpdate?.available) {
|
||||||
|
this.update = foundUpdate;
|
||||||
|
this.status = {
|
||||||
|
checking: false,
|
||||||
|
available: true,
|
||||||
|
version: foundUpdate.version,
|
||||||
|
downloading: false,
|
||||||
|
installing: false,
|
||||||
|
readyToInstall: false,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this.status = {
|
||||||
|
checking: false,
|
||||||
|
available: false,
|
||||||
|
downloading: false,
|
||||||
|
installing: false,
|
||||||
|
readyToInstall: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.notifySubscribers();
|
||||||
|
} catch (error) {
|
||||||
|
this.status = {
|
||||||
|
checking: false,
|
||||||
|
available: false,
|
||||||
|
downloading: false,
|
||||||
|
installing: false,
|
||||||
|
readyToInstall: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to check for updates',
|
||||||
|
};
|
||||||
|
this.notifySubscribers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadAndInstall(): Promise<void> {
|
||||||
|
if (!this.update) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.status = { ...this.status, downloading: true, error: undefined };
|
||||||
|
this.notifySubscribers();
|
||||||
|
|
||||||
|
let downloadedBytes = 0;
|
||||||
|
let totalBytes = 0;
|
||||||
|
|
||||||
|
await this.update.download((event) => {
|
||||||
|
switch (event.event) {
|
||||||
|
case 'Started':
|
||||||
|
totalBytes = event.data.contentLength || 0;
|
||||||
|
downloadedBytes = 0;
|
||||||
|
this.status = {
|
||||||
|
...this.status,
|
||||||
|
downloading: true,
|
||||||
|
totalBytes,
|
||||||
|
downloadedBytes: 0,
|
||||||
|
downloadProgress: 0,
|
||||||
|
};
|
||||||
|
this.notifySubscribers();
|
||||||
|
break;
|
||||||
|
case 'Progress': {
|
||||||
|
downloadedBytes += event.data.chunkLength;
|
||||||
|
const progress =
|
||||||
|
totalBytes > 0 ? Math.round((downloadedBytes / totalBytes) * 100) : undefined;
|
||||||
|
this.status = {
|
||||||
|
...this.status,
|
||||||
|
downloadedBytes,
|
||||||
|
downloadProgress: progress,
|
||||||
|
};
|
||||||
|
this.notifySubscribers();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'Finished':
|
||||||
|
this.status = {
|
||||||
|
...this.status,
|
||||||
|
downloading: false,
|
||||||
|
readyToInstall: true,
|
||||||
|
downloadProgress: 100,
|
||||||
|
};
|
||||||
|
this.notifySubscribers();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.status = {
|
||||||
|
...this.status,
|
||||||
|
downloading: false,
|
||||||
|
installing: false,
|
||||||
|
readyToInstall: false,
|
||||||
|
downloadProgress: undefined,
|
||||||
|
downloadedBytes: undefined,
|
||||||
|
totalBytes: undefined,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to download update',
|
||||||
|
};
|
||||||
|
this.notifySubscribers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async restartAndInstall(): Promise<void> {
|
||||||
|
if (!this.update) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.status = { ...this.status, installing: true, error: undefined };
|
||||||
|
this.notifySubscribers();
|
||||||
|
|
||||||
|
await this.update.install();
|
||||||
|
|
||||||
|
// On Windows with NSIS, the installer handles the restart automatically.
|
||||||
|
// On macOS/Linux, we need to manually relaunch.
|
||||||
|
if (!isWindows()) {
|
||||||
|
await relaunch();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.status = {
|
||||||
|
...this.status,
|
||||||
|
installing: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to install update',
|
||||||
|
};
|
||||||
|
this.notifySubscribers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tauriUpdater = new TauriUpdater();
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/web",
|
"name": "@voicebox/web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.10",
|
"version": "0.1.11",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+19
-1
@@ -1,10 +1,28 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import App from '../../app/src/App';
|
import App from '../../app/src/App';
|
||||||
import '../../app/src/index.css';
|
import '../../app/src/index.css';
|
||||||
|
import { PlatformProvider } from '../../app/src/platform/PlatformContext';
|
||||||
|
import { webPlatform } from './platform';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
gcTime: 1000 * 60 * 10, // 10 minutes
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<PlatformProvider platform={webPlatform}>
|
||||||
|
<App />
|
||||||
|
</PlatformProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { PlatformAudio, AudioDevice } from '@/platform/types';
|
||||||
|
|
||||||
|
export const webAudio: PlatformAudio = {
|
||||||
|
isSystemAudioSupported(): boolean {
|
||||||
|
return false; // System audio capture not supported in web
|
||||||
|
},
|
||||||
|
|
||||||
|
async startSystemAudioCapture(_maxDurationSecs: number): Promise<void> {
|
||||||
|
throw new Error('System audio capture is only available in the desktop app.');
|
||||||
|
},
|
||||||
|
|
||||||
|
async stopSystemAudioCapture(): Promise<Blob> {
|
||||||
|
throw new Error('System audio capture is only available in the desktop app.');
|
||||||
|
},
|
||||||
|
|
||||||
|
async listOutputDevices(): Promise<AudioDevice[]> {
|
||||||
|
return []; // No native device routing in web
|
||||||
|
},
|
||||||
|
|
||||||
|
async playToDevices(_audioData: Uint8Array, _deviceIds: string[]): Promise<void> {
|
||||||
|
throw new Error('Native audio device routing is only available in the desktop app.');
|
||||||
|
},
|
||||||
|
|
||||||
|
stopPlayback(): void {
|
||||||
|
// No-op for web
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { PlatformFilesystem, FileFilter } from '@/platform/types';
|
||||||
|
|
||||||
|
export const webFilesystem: PlatformFilesystem = {
|
||||||
|
async saveFile(filename: string, blob: Blob, _filters?: FileFilter[]) {
|
||||||
|
// Browser: trigger download
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Platform } from '@/platform/types';
|
||||||
|
import { webFilesystem } from './filesystem';
|
||||||
|
import { webUpdater } from './updater';
|
||||||
|
import { webAudio } from './audio';
|
||||||
|
import { webLifecycle } from './lifecycle';
|
||||||
|
import { webMetadata } from './metadata';
|
||||||
|
|
||||||
|
export const webPlatform: Platform = {
|
||||||
|
filesystem: webFilesystem,
|
||||||
|
updater: webUpdater,
|
||||||
|
audio: webAudio,
|
||||||
|
lifecycle: webLifecycle,
|
||||||
|
metadata: webMetadata,
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { PlatformLifecycle } from '@/platform/types';
|
||||||
|
|
||||||
|
class WebLifecycle implements PlatformLifecycle {
|
||||||
|
onServerReady?: () => void;
|
||||||
|
|
||||||
|
async startServer(_remote = false): Promise<string> {
|
||||||
|
// Web assumes server is running externally
|
||||||
|
// Return a default URL - this should be configured via env vars
|
||||||
|
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:17493';
|
||||||
|
this.onServerReady?.();
|
||||||
|
return serverUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopServer(): Promise<void> {
|
||||||
|
// No-op for web - server is managed externally
|
||||||
|
}
|
||||||
|
|
||||||
|
async setKeepServerRunning(_keep: boolean): Promise<void> {
|
||||||
|
// No-op for web
|
||||||
|
}
|
||||||
|
|
||||||
|
async setupWindowCloseHandler(): Promise<void> {
|
||||||
|
// No-op for web - no window close handling needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const webLifecycle = new WebLifecycle();
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { PlatformMetadata } from '@/platform/types';
|
||||||
|
|
||||||
|
export const webMetadata: PlatformMetadata = {
|
||||||
|
async getVersion(): Promise<string> {
|
||||||
|
// Return version from env var or package.json
|
||||||
|
return import.meta.env.VITE_APP_VERSION || '0.1.0';
|
||||||
|
},
|
||||||
|
isTauri: false,
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { PlatformUpdater, UpdateStatus } from '@/platform/types';
|
||||||
|
|
||||||
|
class WebUpdater implements PlatformUpdater {
|
||||||
|
private status: UpdateStatus = {
|
||||||
|
checking: false,
|
||||||
|
available: false,
|
||||||
|
downloading: false,
|
||||||
|
installing: false,
|
||||||
|
readyToInstall: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
private subscribers: Set<(status: UpdateStatus) => void> = new Set();
|
||||||
|
|
||||||
|
private notifySubscribers() {
|
||||||
|
this.subscribers.forEach((callback) => callback(this.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(callback: (status: UpdateStatus) => void): () => void {
|
||||||
|
this.subscribers.add(callback);
|
||||||
|
callback(this.status);
|
||||||
|
return () => {
|
||||||
|
this.subscribers.delete(callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus(): UpdateStatus {
|
||||||
|
return { ...this.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkForUpdates(): Promise<void> {
|
||||||
|
// Web apps don't need client-side updates
|
||||||
|
// Updates are handled by redeploying the web app
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadAndInstall(): Promise<void> {
|
||||||
|
// No-op for web
|
||||||
|
}
|
||||||
|
|
||||||
|
async restartAndInstall(): Promise<void> {
|
||||||
|
// No-op for web
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const webUpdater = new WebUpdater();
|
||||||
Reference in New Issue
Block a user