mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-29 07:05:14 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
236e464525 | ||
|
|
cf3cf3f002 | ||
|
|
3be8980f48 | ||
|
|
341d71470c | ||
|
|
bb6cea24ba | ||
|
|
fa7ac88abc | ||
|
|
c68ddc45b1 | ||
|
|
3c89b068f3 | ||
|
|
229841e05e | ||
|
|
123e8215e4 | ||
|
|
2a3afec2ca | ||
|
|
99ddd5a0b4 | ||
|
|
8d730621bc | ||
|
|
e23118f610 | ||
|
|
d4bfdc0d68 | ||
|
|
116c108906 | ||
|
|
2d23c8e06a | ||
|
|
3973a59ba3 | ||
|
|
d9aa75253a | ||
|
|
b22bf36565 | ||
|
|
33f4ed9b44 | ||
|
|
cc37e04221 | ||
|
|
2b4fbe5173 | ||
|
|
ea943876dc | ||
|
|
b55d8cc567 | ||
|
|
036d90dc8e | ||
|
|
27ae6dfbab | ||
|
|
51b9e2fd3d | ||
|
|
be25ddbe0e | ||
|
|
9cd4921291 | ||
|
|
2349bd24ba | ||
|
|
cd82ed0664 | ||
|
|
c4884a0443 | ||
|
|
232d231788 | ||
|
|
1cf90c81dd | ||
|
|
3204e193fa | ||
|
|
9d5d6cb56a | ||
|
|
153eaba5f3 | ||
|
|
3370e3b419 | ||
|
|
615bd188a0 | ||
|
|
7208f51eee | ||
|
|
07a91a2381 |
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 0.1.4
|
current_version = 0.1.9
|
||||||
commit = True
|
commit = True
|
||||||
tag = True
|
tag = True
|
||||||
tag_name = v{new_version}
|
tag_name = v{new_version}
|
||||||
@@ -34,6 +34,6 @@ replace = "version": "{new_version}"
|
|||||||
search = "version": "{current_version}"
|
search = "version": "{current_version}"
|
||||||
replace = "version": "{new_version}"
|
replace = "version": "{new_version}"
|
||||||
|
|
||||||
[bumpversion:file:backend/main.py]
|
[bumpversion:file:backend/__init__.py]
|
||||||
search = "version": "{current_version}"
|
search = __version__ = "{current_version}"
|
||||||
replace = "version": "{new_version}"
|
replace = __version__ = "{new_version}"
|
||||||
|
|||||||
@@ -115,6 +115,17 @@ First-time usage will be slower due to model downloads, but subsequent runs will
|
|||||||
```
|
```
|
||||||
Creates platform-specific binary in `tauri/src-tauri/binaries/`
|
Creates platform-specific binary in `tauri/src-tauri/binaries/`
|
||||||
|
|
||||||
|
**Building with local Qwen3-TTS development version:**
|
||||||
|
|
||||||
|
If you're actively developing or modifying the Qwen3-TTS library, set the `QWEN_TTS_PATH` environment variable to point to your local clone:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export QWEN_TTS_PATH=~/path/to/your/Qwen3-TTS
|
||||||
|
./scripts/build-server.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This makes PyInstaller use your local qwen-tts version instead of the pip-installed package. Useful when testing changes to the TTS library before they're published to PyPI or when using an editable install (`pip install -e`).
|
||||||
|
|
||||||
**Build Tauri desktop app:**
|
**Build Tauri desktop app:**
|
||||||
```bash
|
```bash
|
||||||
cd tauri
|
cd tauri
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://voicebox.sh">
|
<a href="https://voicebox.sh">
|
||||||
<img src=".github/assets/screenshot.webp" alt="Voicebox App Screenshot" width="800" />
|
<img src="landing/public/assets/app-screenshot-1.webp" alt="Voicebox App Screenshot" width="800" />
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -32,17 +32,29 @@
|
|||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
## Why Voicebox?
|
<p align="center">
|
||||||
|
<img src="landing/public/assets/app-screenshot-2.webp" alt="Voicebox Screenshot 2" width="800" />
|
||||||
|
</p>
|
||||||
|
|
||||||
Voice AI is exploding, but most tools are either cloud-locked, expensive, or a nightmare to set up. Voicebox is different:
|
<p align="center">
|
||||||
|
<img src="landing/public/assets/app-screenshot-3.webp" alt="Voicebox Screenshot 3" width="800" />
|
||||||
|
</p>
|
||||||
|
|
||||||
- **100% Local** — Your voice data never leaves your machine
|
<br/>
|
||||||
- **Lightweight** — No bloated Electron, native Tauri performance
|
|
||||||
- **Fast** — Near-instant on CUDA, optimized for Apple Silicon
|
|
||||||
- **Flexible** — Use the app, integrate the API, or both
|
|
||||||
- **Open Source** — No subscriptions, no limits, no lock-in
|
|
||||||
|
|
||||||
Built with **Tauri** (Rust), **TypeScript**, **React**, and **Python**. Native performance meets modern DX.
|
## What is Voicebox?
|
||||||
|
|
||||||
|
Voicebox is a **local-first voice cloning studio** with DAW-like features for professional voice synthesis. Think of it as the **Ollama for voice** — download models, clone voices, and generate speech entirely on your machine.
|
||||||
|
|
||||||
|
Unlike cloud services that lock your voice data behind subscriptions, Voicebox gives you:
|
||||||
|
|
||||||
|
- **Complete privacy** — models and voice data stay on your machine
|
||||||
|
- **Professional tools** — multi-track timeline editor, audio trimming, conversation mixing
|
||||||
|
- **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
|
||||||
|
- **Native performance** — built with Tauri (Rust), not Electron
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,6 +87,7 @@ Powered by Alibaba's **Qwen3-TTS** — a breakthrough model that achieves near-p
|
|||||||
|
|
||||||
- **Create profiles** from audio files or record directly in-app
|
- **Create profiles** from audio files or record directly in-app
|
||||||
- **Import/Export** profiles to share or backup
|
- **Import/Export** profiles to share or backup
|
||||||
|
- **Multi-sample support** — combine multiple samples for higher quality cloning
|
||||||
- **Organize** with descriptions and language tags
|
- **Organize** with descriptions and language tags
|
||||||
|
|
||||||
### Speech Generation
|
### Speech Generation
|
||||||
@@ -83,9 +96,19 @@ Powered by Alibaba's **Qwen3-TTS** — a breakthrough model that achieves near-p
|
|||||||
- **Batch generation** for long-form content
|
- **Batch generation** for long-form content
|
||||||
- **Smart caching** — regenerate instantly with voice prompt caching
|
- **Smart caching** — regenerate instantly with voice prompt caching
|
||||||
|
|
||||||
|
### Stories Editor
|
||||||
|
|
||||||
|
Create multi-voice narratives, podcasts, and conversations with a timeline-based editor.
|
||||||
|
|
||||||
|
- **Multi-track composition** — arrange multiple voice tracks in a single project
|
||||||
|
- **Inline audio editing** — trim and split clips directly in the timeline
|
||||||
|
- **Auto-playback** — preview stories with synchronized playhead
|
||||||
|
- **Voice mixing** — build conversations with multiple participants
|
||||||
|
|
||||||
### Recording & Transcription
|
### Recording & Transcription
|
||||||
|
|
||||||
- **In-app recording** with waveform visualization
|
- **In-app recording** with waveform visualization
|
||||||
|
- **System audio capture** — record desktop audio on macOS and Windows
|
||||||
- **Automatic transcription** powered by Whisper
|
- **Automatic transcription** powered by Whisper
|
||||||
- **Export recordings** in multiple formats
|
- **Export recordings** in multiple formats
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/app",
|
"name": "@voicebox/app",
|
||||||
"version": "0.1.4",
|
"version": "0.1.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -13,6 +13,9 @@
|
|||||||
"check": "biome check --write src"
|
"check": "biome check --write src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-avatar": "^1.1.0",
|
"@radix-ui/react-avatar": "^1.1.0",
|
||||||
@@ -30,6 +33,7 @@
|
|||||||
"@radix-ui/react-toast": "^1.2.1",
|
"@radix-ui/react-toast": "^1.2.1",
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"@tanstack/react-query-devtools": "^5.0.0",
|
"@tanstack/react-query-devtools": "^5.0.0",
|
||||||
|
"@tanstack/react-router": "^1.157.16",
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||||
"@tauri-apps/plugin-fs": "^2.0.0",
|
"@tauri-apps/plugin-fs": "^2.0.0",
|
||||||
|
|||||||
+9
-77
@@ -1,32 +1,19 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { RouterProvider } from '@tanstack/react-router';
|
||||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||||
import { AppFrame } from '@/components/AppFrame/AppFrame';
|
|
||||||
import { AudioTab } from '@/components/AudioTab/AudioTab';
|
|
||||||
import { MainEditor } from '@/components/MainEditor/MainEditor';
|
|
||||||
import { ModelsTab } from '@/components/ModelsTab/ModelsTab';
|
|
||||||
import { ServerTab } from '@/components/ServerTab/ServerTab';
|
|
||||||
// import { GenerationForm } from '@/components/Generation/GenerationForm';
|
|
||||||
import ShinyText from '@/components/ShinyText';
|
import ShinyText from '@/components/ShinyText';
|
||||||
import { Sidebar } from '@/components/Sidebar';
|
|
||||||
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
||||||
import { Toaster } from '@/components/ui/toaster';
|
|
||||||
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
|
|
||||||
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
|
||||||
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
|
|
||||||
import {
|
import {
|
||||||
isMacOS,
|
|
||||||
isTauri,
|
isTauri,
|
||||||
setKeepServerRunning,
|
setKeepServerRunning,
|
||||||
setupWindowCloseHandler,
|
setupWindowCloseHandler,
|
||||||
startServer,
|
startServer,
|
||||||
} from '@/lib/tauri';
|
} from '@/lib/tauri';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { router } from '@/router';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
// Track if server is starting to prevent duplicate starts
|
|
||||||
let serverStarting = false;
|
|
||||||
|
|
||||||
const LOADING_MESSAGES = [
|
const LOADING_MESSAGES = [
|
||||||
'Warming up tensors...',
|
'Warming up tensors...',
|
||||||
'Calibrating synthesizer engine...',
|
'Calibrating synthesizer engine...',
|
||||||
@@ -51,12 +38,9 @@ const LOADING_MESSAGES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [activeTab, setActiveTab] = useState('main');
|
|
||||||
const [serverReady, setServerReady] = useState(false);
|
const [serverReady, setServerReady] = useState(false);
|
||||||
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
|
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
|
||||||
|
const serverStartingRef = useRef(false);
|
||||||
// Monitor active downloads/generations and show toasts for them
|
|
||||||
const activeDownloads = useRestoreActiveTasks();
|
|
||||||
|
|
||||||
// Sync stored setting to Rust on startup
|
// Sync stored setting to Rust on startup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -92,11 +76,11 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-start server in production
|
// Auto-start server in production
|
||||||
if (serverStarting) {
|
if (serverStartingRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
serverStarting = true;
|
serverStartingRef.current = true;
|
||||||
console.log('Production mode: Starting bundled server...');
|
console.log('Production mode: Starting bundled server...');
|
||||||
|
|
||||||
startServer(false)
|
startServer(false)
|
||||||
@@ -106,13 +90,11 @@ function App() {
|
|||||||
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);
|
||||||
serverStarting = false;
|
serverStartingRef.current = false;
|
||||||
// @ts-expect-error - adding property to window
|
|
||||||
window.__voiceboxServerStartedByApp = false;
|
window.__voiceboxServerStartedByApp = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,7 +102,7 @@ function App() {
|
|||||||
// Note: Window close is handled separately in Tauri Rust code
|
// Note: Window close is handled separately in Tauri Rust code
|
||||||
return () => {
|
return () => {
|
||||||
// Window close event handles server shutdown based on setting
|
// Window close event handles server shutdown based on setting
|
||||||
serverStarting = false;
|
serverStartingRef.current = false;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -172,57 +154,7 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <RouterProvider router={router} />;
|
||||||
<AppFrame>
|
|
||||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
|
||||||
<Sidebar activeTab={activeTab} onTabChange={setActiveTab} isMacOS={isMacOS()} />
|
|
||||||
|
|
||||||
<main className="flex-1 ml-20 overflow-hidden flex flex-col">
|
|
||||||
<div className="container mx-auto px-8 max-w-[1800px] h-full overflow-hidden flex flex-col">
|
|
||||||
{activeTab === 'main' && <MainEditor />}
|
|
||||||
{activeTab === 'voices' && <VoicesTab />}
|
|
||||||
{activeTab === 'audio' && <AudioTab />}
|
|
||||||
{activeTab === 'server' && <ServerTab />}
|
|
||||||
{activeTab === 'models' && <ModelsTab />}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Show download toasts for any active downloads (from anywhere) */}
|
|
||||||
{activeDownloads.map((download) => {
|
|
||||||
const displayName = MODEL_DISPLAY_NAMES[download.model_name] || download.model_name;
|
|
||||||
return (
|
|
||||||
<DownloadToastRestorer
|
|
||||||
key={download.model_name}
|
|
||||||
modelName={download.model_name}
|
|
||||||
displayName={displayName}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<Toaster />
|
|
||||||
</AppFrame>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component that restores a download toast for a specific model.
|
|
||||||
*/
|
|
||||||
function DownloadToastRestorer({
|
|
||||||
modelName,
|
|
||||||
displayName,
|
|
||||||
}: {
|
|
||||||
modelName: string;
|
|
||||||
displayName: string;
|
|
||||||
}) {
|
|
||||||
// Use the download toast hook to restore the toast
|
|
||||||
useModelDownloadToast({
|
|
||||||
modelName,
|
|
||||||
displayName,
|
|
||||||
enabled: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -1,18 +1,35 @@
|
|||||||
|
import { useRouterState } from '@tanstack/react-router';
|
||||||
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
||||||
import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
|
import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
|
||||||
|
import { StoryTrackEditor } from '@/components/StoriesTab/StoryTrackEditor';
|
||||||
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
|
import { useStory } from '@/lib/hooks/useStories';
|
||||||
|
|
||||||
interface AppFrameProps {
|
interface AppFrameProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppFrame({ children }: AppFrameProps) {
|
export function AppFrame({ children }: AppFrameProps) {
|
||||||
|
const routerState = useRouterState();
|
||||||
|
const isStoriesRoute = routerState.location.pathname === '/stories';
|
||||||
|
|
||||||
|
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||||
|
const { data: story } = useStory(selectedStoryId);
|
||||||
|
|
||||||
|
// Show track editor when on stories route with a selected story that has items
|
||||||
|
const showTrackEditor = isStoriesRoute && selectedStoryId && story && story.items.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('h-screen bg-background flex flex-col overflow-hidden', TOP_SAFE_AREA_PADDING)}>
|
<div className={cn('h-screen bg-background flex flex-col overflow-hidden', TOP_SAFE_AREA_PADDING)}>
|
||||||
<TitleBarDragRegion />
|
<TitleBarDragRegion />
|
||||||
{children}
|
{children}
|
||||||
<AudioPlayer />
|
{showTrackEditor ? (
|
||||||
|
<StoryTrackEditor storyId={story.id} items={story.items} />
|
||||||
|
) : (
|
||||||
|
<AudioPlayer />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Slider } from '@/components/ui/slider';
|
|||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { isTauri } from '@/lib/tauri';
|
import { isTauri } from '@/lib/tauri';
|
||||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||||
|
import { debug } from '@/lib/utils/debug';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
export function AudioPlayer() {
|
export function AudioPlayer() {
|
||||||
@@ -49,28 +50,17 @@ export function AudioPlayer() {
|
|||||||
|
|
||||||
// Determine if we should use native playback
|
// Determine if we should use native playback
|
||||||
const useNativePlayback = useMemo(() => {
|
const useNativePlayback = useMemo(() => {
|
||||||
console.log('useNativePlayback memo:', {
|
|
||||||
isTauri: isTauri(),
|
|
||||||
profileId,
|
|
||||||
profileChannels,
|
|
||||||
channels,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isTauri() || !profileChannels || !channels) {
|
if (!isTauri() || !profileChannels || !channels) {
|
||||||
console.log('useNativePlayback: false - missing requirements');
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const assignedChannels = channels.filter((ch) => profileChannels.channel_ids.includes(ch.id));
|
const assignedChannels = channels.filter((ch) => profileChannels.channel_ids.includes(ch.id));
|
||||||
|
|
||||||
console.log('Assigned channels:', assignedChannels);
|
|
||||||
|
|
||||||
// Use native playback if any assigned channel has non-default devices
|
// Use native playback if any assigned channel has non-default devices
|
||||||
const shouldUseNative = assignedChannels.some(
|
const shouldUseNative = assignedChannels.some(
|
||||||
(ch) => ch.device_ids.length > 0 && !ch.is_default,
|
(ch) => ch.device_ids.length > 0 && !ch.is_default,
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('useNativePlayback result:', shouldUseNative);
|
|
||||||
return shouldUseNative;
|
return shouldUseNative;
|
||||||
}, [profileChannels, channels, profileId]);
|
}, [profileChannels, channels, profileId]);
|
||||||
|
|
||||||
@@ -91,11 +81,11 @@ export function AudioPlayer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (wavesurferRef.current) {
|
if (wavesurferRef.current) {
|
||||||
console.log('WaveSurfer already initialized, skipping');
|
debug.log('WaveSurfer already initialized, skipping');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Creating NEW WaveSurfer instance');
|
debug.log('Creating NEW WaveSurfer instance');
|
||||||
|
|
||||||
// Wait for container to be properly rendered
|
// Wait for container to be properly rendered
|
||||||
const initWaveSurfer = () => {
|
const initWaveSurfer = () => {
|
||||||
@@ -121,7 +111,7 @@ export function AudioPlayer() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Initializing WaveSurfer...', {
|
debug.log('Initializing WaveSurfer...', {
|
||||||
container,
|
container,
|
||||||
width: rect.width,
|
width: rect.width,
|
||||||
height: rect.height,
|
height: rect.height,
|
||||||
@@ -154,9 +144,9 @@ export function AudioPlayer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
wavesurferRef.current = wavesurfer;
|
wavesurferRef.current = wavesurfer;
|
||||||
console.log('WaveSurfer created successfully');
|
debug.log('WaveSurfer created successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create WaveSurfer:', error);
|
debug.error('Failed to create WaveSurfer:', error);
|
||||||
setError(
|
setError(
|
||||||
`Failed to initialize waveform: ${error instanceof Error ? error.message : String(error)}`,
|
`Failed to initialize waveform: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
);
|
);
|
||||||
@@ -178,8 +168,8 @@ export function AudioPlayer() {
|
|||||||
loadingRef.current = false;
|
loadingRef.current = false;
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
console.log('Audio ready, duration:', dur);
|
debug.log('Audio ready, duration:', dur);
|
||||||
console.log('Waveform should be visible now');
|
debug.log('Waveform should be visible now');
|
||||||
|
|
||||||
// Ensure volume is set
|
// Ensure volume is set
|
||||||
const currentVolume = usePlayerStore.getState().volume;
|
const currentVolume = usePlayerStore.getState().volume;
|
||||||
@@ -191,7 +181,7 @@ export function AudioPlayer() {
|
|||||||
if (mediaElement && !isUsingNativePlaybackRef.current) {
|
if (mediaElement && !isUsingNativePlaybackRef.current) {
|
||||||
mediaElement.volume = currentVolume;
|
mediaElement.volume = currentVolume;
|
||||||
mediaElement.muted = false;
|
mediaElement.muted = false;
|
||||||
console.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted);
|
debug.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-play when ready - check if we should use native playback
|
// Auto-play when ready - check if we should use native playback
|
||||||
@@ -199,7 +189,7 @@ export function AudioPlayer() {
|
|||||||
const currentAudioUrl = usePlayerStore.getState().audioUrl;
|
const currentAudioUrl = usePlayerStore.getState().audioUrl;
|
||||||
const currentProfileId = usePlayerStore.getState().profileId;
|
const currentProfileId = usePlayerStore.getState().profileId;
|
||||||
|
|
||||||
console.log('Auto-play check - capturing runtime values...');
|
debug.log('Auto-play check - capturing runtime values...');
|
||||||
|
|
||||||
// Fetch profile channels at runtime (not using captured value)
|
// Fetch profile channels at runtime (not using captured value)
|
||||||
let runtimeProfileChannels = null;
|
let runtimeProfileChannels = null;
|
||||||
@@ -208,18 +198,18 @@ export function AudioPlayer() {
|
|||||||
if (isTauri() && currentProfileId) {
|
if (isTauri() && currentProfileId) {
|
||||||
try {
|
try {
|
||||||
runtimeProfileChannels = await apiClient.getProfileChannels(currentProfileId);
|
runtimeProfileChannels = await apiClient.getProfileChannels(currentProfileId);
|
||||||
console.log('Runtime profileChannels:', runtimeProfileChannels);
|
debug.log('Runtime profileChannels:', runtimeProfileChannels);
|
||||||
|
|
||||||
if (runtimeProfileChannels && runtimeProfileChannels.channel_ids.length > 0) {
|
if (runtimeProfileChannels && runtimeProfileChannels.channel_ids.length > 0) {
|
||||||
runtimeChannels = await apiClient.listChannels();
|
runtimeChannels = await apiClient.listChannels();
|
||||||
console.log('Runtime channels:', runtimeChannels);
|
debug.log('Runtime channels:', runtimeChannels);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch runtime channel data:', error);
|
debug.error('Failed to fetch runtime channel data:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Auto-play check:', {
|
debug.log('Auto-play check:', {
|
||||||
isTauri: isTauri(),
|
isTauri: isTauri(),
|
||||||
currentAudioUrl,
|
currentAudioUrl,
|
||||||
currentProfileId,
|
currentProfileId,
|
||||||
@@ -234,15 +224,15 @@ export function AudioPlayer() {
|
|||||||
runtimeProfileChannels &&
|
runtimeProfileChannels &&
|
||||||
runtimeChannels
|
runtimeChannels
|
||||||
) {
|
) {
|
||||||
console.log('Attempting native audio playback...');
|
debug.log('Attempting native audio playback...');
|
||||||
|
|
||||||
// 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');
|
await invoke('stop_audio_playback');
|
||||||
console.log('Stopped existing native playback before starting new one');
|
debug.log('Stopped existing native playback before starting new one');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to stop existing playback:', error);
|
debug.error('Failed to stop existing playback:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,16 +241,16 @@ export function AudioPlayer() {
|
|||||||
const assignedChannels = runtimeChannels.filter((ch: any) =>
|
const assignedChannels = runtimeChannels.filter((ch: any) =>
|
||||||
runtimeProfileChannels.channel_ids.includes(ch.id),
|
runtimeProfileChannels.channel_ids.includes(ch.id),
|
||||||
);
|
);
|
||||||
console.log('Assigned channels for playback:', assignedChannels);
|
debug.log('Assigned channels for playback:', assignedChannels);
|
||||||
|
|
||||||
// Check if any assigned channel has non-default devices
|
// Check if any assigned channel has non-default devices
|
||||||
const shouldUseNative = assignedChannels.some(
|
const shouldUseNative = assignedChannels.some(
|
||||||
(ch: any) => ch.device_ids.length > 0 && !ch.is_default,
|
(ch: any) => ch.device_ids.length > 0 && !ch.is_default,
|
||||||
);
|
);
|
||||||
console.log('Should use native playback:', shouldUseNative);
|
debug.log('Should use native playback:', shouldUseNative);
|
||||||
|
|
||||||
if (!shouldUseNative) {
|
if (!shouldUseNative) {
|
||||||
console.log('No custom devices assigned, falling back to WaveSurfer');
|
debug.log('No custom devices assigned, falling back to WaveSurfer');
|
||||||
// Reset native playback flag and unmute WaveSurfer
|
// Reset native playback flag and unmute WaveSurfer
|
||||||
isUsingNativePlaybackRef.current = false;
|
isUsingNativePlaybackRef.current = false;
|
||||||
const mediaElement = wavesurfer.getMediaElement();
|
const mediaElement = wavesurfer.getMediaElement();
|
||||||
@@ -268,7 +258,7 @@ export function AudioPlayer() {
|
|||||||
const currentVolume = usePlayerStore.getState().volume;
|
const currentVolume = usePlayerStore.getState().volume;
|
||||||
mediaElement.volume = currentVolume;
|
mediaElement.volume = currentVolume;
|
||||||
mediaElement.muted = false;
|
mediaElement.muted = false;
|
||||||
console.log(
|
debug.log(
|
||||||
'WaveSurfer unmuted for normal playback - volume:',
|
'WaveSurfer unmuted for normal playback - volume:',
|
||||||
mediaElement.volume,
|
mediaElement.volume,
|
||||||
'muted:',
|
'muted:',
|
||||||
@@ -277,23 +267,23 @@ export function AudioPlayer() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const deviceIds = assignedChannels.flatMap((ch: any) => ch.device_ids);
|
const deviceIds = assignedChannels.flatMap((ch: any) => ch.device_ids);
|
||||||
console.log('Device IDs to play to:', deviceIds);
|
debug.log('Device IDs to play to:', deviceIds);
|
||||||
|
|
||||||
if (deviceIds.length > 0) {
|
if (deviceIds.length > 0) {
|
||||||
console.log('Fetching audio data from:', currentAudioUrl);
|
debug.log('Fetching audio data from:', currentAudioUrl);
|
||||||
// Fetch audio data
|
// Fetch audio data
|
||||||
const response = await fetch(currentAudioUrl);
|
const response = await fetch(currentAudioUrl);
|
||||||
const audioData = new Uint8Array(await response.arrayBuffer());
|
const audioData = new Uint8Array(await response.arrayBuffer());
|
||||||
console.log('Audio data size:', audioData.length);
|
debug.log('Audio data size:', audioData.length);
|
||||||
|
|
||||||
// Play via native audio
|
// Play via native audio
|
||||||
console.log('Invoking play_audio_to_devices...');
|
debug.log('Invoking play_audio_to_devices...');
|
||||||
try {
|
try {
|
||||||
const result = await invoke('play_audio_to_devices', {
|
const result = await invoke('play_audio_to_devices', {
|
||||||
audioData: Array.from(audioData),
|
audioData: Array.from(audioData),
|
||||||
deviceIds: deviceIds,
|
deviceIds: deviceIds,
|
||||||
});
|
});
|
||||||
console.log('play_audio_to_devices completed successfully, result:', result);
|
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;
|
||||||
@@ -304,7 +294,7 @@ export function AudioPlayer() {
|
|||||||
if (mediaElement) {
|
if (mediaElement) {
|
||||||
mediaElement.volume = 0;
|
mediaElement.volume = 0;
|
||||||
mediaElement.muted = true;
|
mediaElement.muted = true;
|
||||||
console.log(
|
debug.log(
|
||||||
'WaveSurfer muted for native playback - volume:',
|
'WaveSurfer muted for native playback - volume:',
|
||||||
mediaElement.volume,
|
mediaElement.volume,
|
||||||
'muted:',
|
'muted:',
|
||||||
@@ -314,22 +304,22 @@ export function AudioPlayer() {
|
|||||||
|
|
||||||
// Start WaveSurfer playback for visualization (muted)
|
// Start WaveSurfer playback for visualization (muted)
|
||||||
wavesurfer.play().catch((error) => {
|
wavesurfer.play().catch((error) => {
|
||||||
console.error('Failed to start WaveSurfer visualization:', error);
|
debug.error('Failed to start WaveSurfer visualization:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
console.log('Auto-playing via native audio routing - SUCCESS');
|
debug.log('Auto-playing via native audio routing - SUCCESS');
|
||||||
return;
|
return;
|
||||||
} catch (invokeError) {
|
} catch (invokeError) {
|
||||||
console.error('play_audio_to_devices invoke failed:', invokeError);
|
debug.error('play_audio_to_devices invoke failed:', invokeError);
|
||||||
throw invokeError;
|
throw invokeError;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('No device IDs found, falling back to WaveSurfer');
|
debug.log('No device IDs found, falling back to WaveSurfer');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
debug.error(
|
||||||
'Native playback failed during auto-play, falling back to WaveSurfer:',
|
'Native playback failed during auto-play, falling back to WaveSurfer:',
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
@@ -340,7 +330,7 @@ export function AudioPlayer() {
|
|||||||
const currentVolume = usePlayerStore.getState().volume;
|
const currentVolume = usePlayerStore.getState().volume;
|
||||||
mediaElement.volume = currentVolume;
|
mediaElement.volume = currentVolume;
|
||||||
mediaElement.muted = false;
|
mediaElement.muted = false;
|
||||||
console.log(
|
debug.log(
|
||||||
'WaveSurfer unmuted after native playback failure - volume:',
|
'WaveSurfer unmuted after native playback failure - volume:',
|
||||||
mediaElement.volume,
|
mediaElement.volume,
|
||||||
'muted:',
|
'muted:',
|
||||||
@@ -350,7 +340,7 @@ export function AudioPlayer() {
|
|||||||
// Fall through to WaveSurfer playback
|
// Fall through to WaveSurfer playback
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('Not using native playback, using WaveSurfer');
|
debug.log('Not using native playback, using WaveSurfer');
|
||||||
// Reset native playback flag and unmute WaveSurfer
|
// Reset native playback flag and unmute WaveSurfer
|
||||||
isUsingNativePlaybackRef.current = false;
|
isUsingNativePlaybackRef.current = false;
|
||||||
const mediaElement = wavesurfer.getMediaElement();
|
const mediaElement = wavesurfer.getMediaElement();
|
||||||
@@ -358,7 +348,7 @@ export function AudioPlayer() {
|
|||||||
const currentVolume = usePlayerStore.getState().volume;
|
const currentVolume = usePlayerStore.getState().volume;
|
||||||
mediaElement.volume = currentVolume;
|
mediaElement.volume = currentVolume;
|
||||||
mediaElement.muted = false;
|
mediaElement.muted = false;
|
||||||
console.log(
|
debug.log(
|
||||||
'WaveSurfer unmuted for normal playback - volume:',
|
'WaveSurfer unmuted for normal playback - volume:',
|
||||||
mediaElement.volume,
|
mediaElement.volume,
|
||||||
'muted:',
|
'muted:',
|
||||||
@@ -367,14 +357,22 @@ export function AudioPlayer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standard WaveSurfer auto-play
|
// Only auto-play if shouldAutoPlay flag is set (user explicitly clicked to play)
|
||||||
// Use a small delay to ensure audio element is fully ready
|
const shouldAutoPlayNow = usePlayerStore.getState().shouldAutoPlay;
|
||||||
setTimeout(() => {
|
if (shouldAutoPlayNow) {
|
||||||
wavesurfer.play().catch((error) => {
|
// Clear the flag first
|
||||||
console.error('Failed to autoplay:', error);
|
usePlayerStore.getState().clearAutoPlayFlag();
|
||||||
// Don't show error for autoplay failures (browser restrictions)
|
|
||||||
});
|
// Use a small delay to ensure audio element is fully ready
|
||||||
}, 100);
|
setTimeout(() => {
|
||||||
|
wavesurfer.play().catch((error) => {
|
||||||
|
debug.error('Failed to autoplay:', error);
|
||||||
|
// Don't show error for autoplay failures (browser restrictions)
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
} else {
|
||||||
|
debug.log('Skipping auto-play - shouldAutoPlay is false');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle play/pause
|
// Handle play/pause
|
||||||
@@ -388,13 +386,13 @@ export function AudioPlayer() {
|
|||||||
if (isUsingNativePlaybackRef.current) {
|
if (isUsingNativePlaybackRef.current) {
|
||||||
mediaElement.volume = 0;
|
mediaElement.volume = 0;
|
||||||
mediaElement.muted = true;
|
mediaElement.muted = true;
|
||||||
console.log('Playing (native mode) - WaveSurfer muted for visualization only');
|
debug.log('Playing (native mode) - WaveSurfer muted for visualization only');
|
||||||
} else {
|
} else {
|
||||||
// Ensure WaveSurfer is unmuted for normal playback
|
// Ensure WaveSurfer is unmuted for normal playback
|
||||||
const currentVolume = usePlayerStore.getState().volume;
|
const currentVolume = usePlayerStore.getState().volume;
|
||||||
mediaElement.volume = currentVolume;
|
mediaElement.volume = currentVolume;
|
||||||
mediaElement.muted = false;
|
mediaElement.muted = false;
|
||||||
console.log(
|
debug.log(
|
||||||
'Playing (normal mode) - volume:',
|
'Playing (normal mode) - volume:',
|
||||||
mediaElement.volume,
|
mediaElement.volume,
|
||||||
'muted:',
|
'muted:',
|
||||||
@@ -412,12 +410,17 @@ export function AudioPlayer() {
|
|||||||
wavesurfer.play();
|
wavesurfer.play();
|
||||||
} else {
|
} else {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
|
// Trigger finish callback if set
|
||||||
|
const onFinish = usePlayerStore.getState().onFinish;
|
||||||
|
if (onFinish) {
|
||||||
|
onFinish();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle errors
|
// Handle errors
|
||||||
wavesurfer.on('error', (error) => {
|
wavesurfer.on('error', (error) => {
|
||||||
console.error('WaveSurfer error:', error);
|
debug.error('WaveSurfer error:', error);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setError(`Audio error: ${error instanceof Error ? error.message : String(error)}`);
|
setError(`Audio error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
});
|
});
|
||||||
@@ -432,7 +435,7 @@ export function AudioPlayer() {
|
|||||||
|
|
||||||
// Load audio immediately if audioUrl is already set
|
// Load audio immediately if audioUrl is already set
|
||||||
if (audioUrl) {
|
if (audioUrl) {
|
||||||
console.log('WaveSurfer ready, loading audio:', audioUrl);
|
debug.log('WaveSurfer ready, loading audio:', audioUrl);
|
||||||
loadingRef.current = true;
|
loadingRef.current = true;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
// Stop any current playback before loading new audio
|
// Stop any current playback before loading new audio
|
||||||
@@ -442,11 +445,11 @@ export function AudioPlayer() {
|
|||||||
wavesurfer
|
wavesurfer
|
||||||
.load(audioUrl)
|
.load(audioUrl)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('Audio loaded into WaveSurfer');
|
debug.log('Audio loaded into WaveSurfer');
|
||||||
loadingRef.current = false;
|
loadingRef.current = false;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to load audio into WaveSurfer:', error);
|
debug.error('Failed to load audio into WaveSurfer:', error);
|
||||||
loadingRef.current = false;
|
loadingRef.current = false;
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setError(
|
setError(
|
||||||
@@ -471,12 +474,12 @@ export function AudioPlayer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
console.log('Cleaning up WaveSurfer initialization effect');
|
debug.log('Cleaning up WaveSurfer initialization effect');
|
||||||
if (rafId1) cancelAnimationFrame(rafId1);
|
if (rafId1) cancelAnimationFrame(rafId1);
|
||||||
if (rafId2) cancelAnimationFrame(rafId2);
|
if (rafId2) cancelAnimationFrame(rafId2);
|
||||||
if (timeoutId) clearTimeout(timeoutId);
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
if (wavesurferRef.current) {
|
if (wavesurferRef.current) {
|
||||||
console.log('Destroying WaveSurfer instance');
|
debug.log('Destroying WaveSurfer instance');
|
||||||
try {
|
try {
|
||||||
const mediaElement = wavesurferRef.current.getMediaElement();
|
const mediaElement = wavesurferRef.current.getMediaElement();
|
||||||
if (mediaElement) {
|
if (mediaElement) {
|
||||||
@@ -485,7 +488,7 @@ export function AudioPlayer() {
|
|||||||
}
|
}
|
||||||
wavesurferRef.current.destroy();
|
wavesurferRef.current.destroy();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error destroying WaveSurfer:', error);
|
debug.error('Error destroying WaveSurfer:', error);
|
||||||
}
|
}
|
||||||
wavesurferRef.current = null;
|
wavesurferRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -517,9 +520,9 @@ export function AudioPlayer() {
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
await invoke('stop_audio_playback');
|
await invoke('stop_audio_playback');
|
||||||
console.log('Stopped native audio playback');
|
debug.log('Stopped native audio playback');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to stop native playback:', error);
|
debug.error('Failed to stop native playback:', error);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
@@ -537,30 +540,30 @@ export function AudioPlayer() {
|
|||||||
|
|
||||||
// CRITICAL: Force stop any current playback and cancel any pending loads
|
// CRITICAL: Force stop any current playback and cancel any pending loads
|
||||||
// This must happen BEFORE any early returns
|
// This must happen BEFORE any early returns
|
||||||
console.log('Audio URL changed to:', audioUrl);
|
debug.log('Audio URL changed to:', audioUrl);
|
||||||
|
|
||||||
// COMPLETELY stop and destroy the current audio
|
// COMPLETELY stop and destroy the current audio
|
||||||
try {
|
try {
|
||||||
// First pause if playing
|
// First pause if playing
|
||||||
if (wavesurfer.isPlaying()) {
|
if (wavesurfer.isPlaying()) {
|
||||||
console.log('Pausing current playback');
|
debug.log('Pausing current playback');
|
||||||
wavesurfer.pause();
|
wavesurfer.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop the media element explicitly
|
// Stop the media element explicitly
|
||||||
const mediaElement = wavesurfer.getMediaElement();
|
const mediaElement = wavesurfer.getMediaElement();
|
||||||
if (mediaElement) {
|
if (mediaElement) {
|
||||||
console.log('Stopping media element');
|
debug.log('Stopping media element');
|
||||||
mediaElement.pause();
|
mediaElement.pause();
|
||||||
mediaElement.currentTime = 0;
|
mediaElement.currentTime = 0;
|
||||||
mediaElement.src = '';
|
mediaElement.src = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use empty() to completely destroy the waveform and media element
|
// Use empty() to completely destroy the waveform and media element
|
||||||
console.log('Calling wavesurfer.empty() to destroy audio');
|
debug.log('Calling wavesurfer.empty() to destroy audio');
|
||||||
wavesurfer.empty();
|
wavesurfer.empty();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error stopping previous audio:', error);
|
debug.error('Error stopping previous audio:', error);
|
||||||
// Continue anyway to load new audio
|
// Continue anyway to load new audio
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,16 +578,16 @@ export function AudioPlayer() {
|
|||||||
setDuration(0);
|
setDuration(0);
|
||||||
|
|
||||||
// Load new audio
|
// Load new audio
|
||||||
console.log('Starting new audio load for:', audioUrl);
|
debug.log('Starting new audio load for:', audioUrl);
|
||||||
wavesurfer
|
wavesurfer
|
||||||
.load(audioUrl)
|
.load(audioUrl)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('Audio load promise resolved');
|
debug.log('Audio load promise resolved');
|
||||||
// Don't set loading to false here - wait for 'ready' event
|
// Don't set loading to false here - wait for 'ready' event
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to load audio:', error);
|
debug.error('Failed to load audio:', error);
|
||||||
console.error('Audio URL:', audioUrl);
|
debug.error('Audio URL:', audioUrl);
|
||||||
loadingRef.current = false;
|
loadingRef.current = false;
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setError(`Failed to load audio: ${error instanceof Error ? error.message : String(error)}`);
|
setError(`Failed to load audio: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
@@ -599,7 +602,7 @@ export function AudioPlayer() {
|
|||||||
if (isPlaying && wavesurferRef.current.isPlaying() === false) {
|
if (isPlaying && wavesurferRef.current.isPlaying() === false) {
|
||||||
// Only auto-play if audio is ready
|
// Only auto-play if audio is ready
|
||||||
wavesurferRef.current.play().catch((error) => {
|
wavesurferRef.current.play().catch((error) => {
|
||||||
console.error('Failed to play:', error);
|
debug.error('Failed to play:', error);
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
});
|
});
|
||||||
@@ -619,11 +622,11 @@ export function AudioPlayer() {
|
|||||||
if (isUsingNativePlaybackRef.current) {
|
if (isUsingNativePlaybackRef.current) {
|
||||||
mediaElement.volume = 0;
|
mediaElement.volume = 0;
|
||||||
mediaElement.muted = true;
|
mediaElement.muted = true;
|
||||||
console.log('Volume sync: Using native playback, keeping WaveSurfer muted');
|
debug.log('Volume sync: Using native playback, keeping WaveSurfer muted');
|
||||||
} else {
|
} else {
|
||||||
mediaElement.volume = volume;
|
mediaElement.volume = volume;
|
||||||
mediaElement.muted = volume === 0;
|
mediaElement.muted = volume === 0;
|
||||||
console.log('Volume synced:', volume, 'muted:', mediaElement.muted);
|
debug.log('Volume synced:', volume, 'muted:', mediaElement.muted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -651,10 +654,10 @@ export function AudioPlayer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reset to beginning and play
|
// Reset to beginning and play
|
||||||
console.log('Restarting current audio from beginning');
|
debug.log('Restarting current audio from beginning');
|
||||||
wavesurfer.seekTo(0);
|
wavesurfer.seekTo(0);
|
||||||
wavesurfer.play().catch((error) => {
|
wavesurfer.play().catch((error) => {
|
||||||
console.error('Failed to play after restart:', error);
|
debug.error('Failed to play after restart:', error);
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
});
|
});
|
||||||
@@ -663,19 +666,42 @@ export function AudioPlayer() {
|
|||||||
clearRestartFlag();
|
clearRestartFlag();
|
||||||
}, [shouldRestart, duration, setIsPlaying, clearRestartFlag]);
|
}, [shouldRestart, duration, setIsPlaying, clearRestartFlag]);
|
||||||
|
|
||||||
|
// Handle shouldAutoPlay flag - for story mode auto-advance
|
||||||
|
const shouldAutoPlay = usePlayerStore((state) => state.shouldAutoPlay);
|
||||||
|
const clearAutoPlayFlag = usePlayerStore((state) => state.clearAutoPlayFlag);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wavesurfer = wavesurferRef.current;
|
||||||
|
if (!wavesurfer || !shouldAutoPlay || duration === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-play the newly loaded audio
|
||||||
|
debug.log('Auto-playing next track in story mode');
|
||||||
|
wavesurfer.seekTo(0);
|
||||||
|
wavesurfer.play().catch((error) => {
|
||||||
|
debug.error('Failed to auto-play:', error);
|
||||||
|
setIsPlaying(false);
|
||||||
|
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear the auto-play flag
|
||||||
|
clearAutoPlayFlag();
|
||||||
|
}, [shouldAutoPlay, duration, setIsPlaying, clearAutoPlayFlag]);
|
||||||
|
|
||||||
// Handle loop - WaveSurfer handles this via the 'finish' event
|
// Handle loop - WaveSurfer handles this via the 'finish' event
|
||||||
|
|
||||||
const handlePlayPause = async () => {
|
const handlePlayPause = async () => {
|
||||||
// Standard WaveSurfer playback (works for both normal and native playback modes)
|
// Standard WaveSurfer playback (works for both normal and native playback modes)
|
||||||
// When using native playback, WaveSurfer is muted but still controls visualization
|
// When using native playback, WaveSurfer is muted but still controls visualization
|
||||||
if (!wavesurferRef.current) {
|
if (!wavesurferRef.current) {
|
||||||
console.error('WaveSurfer not initialized');
|
debug.error('WaveSurfer not initialized');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if audio is loaded
|
// Check if audio is loaded
|
||||||
if (duration === 0 && !isLoading) {
|
if (duration === 0 && !isLoading) {
|
||||||
console.error('Audio not loaded yet');
|
debug.error('Audio not loaded yet');
|
||||||
setError('Audio not loaded. Please wait...');
|
setError('Audio not loaded. Please wait...');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -686,9 +712,9 @@ export function AudioPlayer() {
|
|||||||
// Pause: stop native playback and pause WaveSurfer visualization
|
// Pause: stop native playback and pause WaveSurfer visualization
|
||||||
try {
|
try {
|
||||||
await invoke('stop_audio_playback');
|
await invoke('stop_audio_playback');
|
||||||
console.log('Stopped native audio playback');
|
debug.log('Stopped native audio playback');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to stop native playback:', error);
|
debug.error('Failed to stop native playback:', error);
|
||||||
}
|
}
|
||||||
wavesurferRef.current.pause();
|
wavesurferRef.current.pause();
|
||||||
return;
|
return;
|
||||||
@@ -701,7 +727,7 @@ export function AudioPlayer() {
|
|||||||
await invoke('stop_audio_playback');
|
await invoke('stop_audio_playback');
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// Ignore errors when stopping (might not be playing)
|
// Ignore errors when stopping (might not be playing)
|
||||||
console.log('No existing playback to stop');
|
debug.log('No existing playback to stop');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all device IDs from assigned channels
|
// Collect all device IDs from assigned channels
|
||||||
@@ -733,7 +759,7 @@ export function AudioPlayer() {
|
|||||||
|
|
||||||
// Start WaveSurfer for visualization (muted)
|
// Start WaveSurfer for visualization (muted)
|
||||||
wavesurferRef.current.play().catch((error) => {
|
wavesurferRef.current.play().catch((error) => {
|
||||||
console.error('Failed to start WaveSurfer visualization:', error);
|
debug.error('Failed to start WaveSurfer visualization:', error);
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
});
|
});
|
||||||
@@ -741,7 +767,7 @@ export function AudioPlayer() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Native playback failed, falling back to WaveSurfer:', error);
|
debug.error('Native playback failed, falling back to WaveSurfer:', error);
|
||||||
// Fall through to WaveSurfer playback
|
// Fall through to WaveSurfer playback
|
||||||
isUsingNativePlaybackRef.current = false;
|
isUsingNativePlaybackRef.current = false;
|
||||||
}
|
}
|
||||||
@@ -761,7 +787,7 @@ export function AudioPlayer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
wavesurferRef.current.play().catch((error) => {
|
wavesurferRef.current.play().catch((error) => {
|
||||||
console.error('Failed to play:', error);
|
debug.error('Failed to play:', error);
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
});
|
});
|
||||||
@@ -782,7 +808,7 @@ export function AudioPlayer() {
|
|||||||
// Stop any native playback
|
// Stop any native playback
|
||||||
if (isUsingNativePlaybackRef.current && isTauri()) {
|
if (isUsingNativePlaybackRef.current && isTauri()) {
|
||||||
invoke('stop_audio_playback').catch((error) => {
|
invoke('stop_audio_playback').catch((error) => {
|
||||||
console.error('Failed to stop native playback:', error);
|
debug.error('Failed to stop native playback:', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Stop WaveSurfer
|
// Stop WaveSurfer
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { useMatchRoute } from '@tanstack/react-router';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { Loader2, Sparkles } from 'lucide-react';
|
import { Loader2, MessageSquare, Sparkles } from 'lucide-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import * as z from 'zod';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
||||||
import {
|
import {
|
||||||
@@ -15,51 +13,65 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
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 { LANGUAGE_OPTIONS } from '@/lib/constants/languages';
|
||||||
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
|
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
||||||
import { useGeneration } from '@/lib/hooks/useGeneration';
|
import { useProfile, useProfiles } from '@/lib/hooks/useProfiles';
|
||||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
import { useAddStoryItem, useStory } from '@/lib/hooks/useStories';
|
||||||
import { useProfile } from '@/lib/hooks/useProfiles';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
|
||||||
const generationSchema = z.object({
|
|
||||||
text: z.string().min(1, 'Text is required').max(5000),
|
|
||||||
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
|
||||||
modelSize: z.enum(['1.7B', '0.6B']).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type GenerationFormValues = z.infer<typeof generationSchema>;
|
|
||||||
|
|
||||||
interface FloatingGenerateBoxProps {
|
interface FloatingGenerateBoxProps {
|
||||||
isPlayerOpen: boolean;
|
isPlayerOpen?: boolean;
|
||||||
|
showVoiceSelector?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps) {
|
export function FloatingGenerateBox({
|
||||||
|
isPlayerOpen = false,
|
||||||
|
showVoiceSelector = false,
|
||||||
|
}: FloatingGenerateBoxProps) {
|
||||||
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||||
|
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
|
||||||
const { data: selectedProfile } = useProfile(selectedProfileId || '');
|
const { data: selectedProfile } = useProfile(selectedProfileId || '');
|
||||||
const generation = useGeneration();
|
const { data: profiles } = useProfiles();
|
||||||
const { toast } = useToast();
|
|
||||||
const setAudio = usePlayerStore((state) => state.setAudio);
|
|
||||||
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
|
|
||||||
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
|
|
||||||
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [isInstructMode, setIsInstructMode] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const matchRoute = useMatchRoute();
|
||||||
|
const isStoriesRoute = matchRoute({ to: '/stories' });
|
||||||
|
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||||
|
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||||
|
const { data: currentStory } = useStory(selectedStoryId);
|
||||||
|
const addStoryItem = useAddStoryItem();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
useModelDownloadToast({
|
// Calculate if track editor is visible (on stories route with items)
|
||||||
modelName: downloadingModelName || '',
|
const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0;
|
||||||
displayName: downloadingDisplayName || '',
|
|
||||||
enabled: !!downloadingModelName,
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm<GenerationFormValues>({
|
const { form, handleSubmit, isPending } = useGenerationForm({
|
||||||
resolver: zodResolver(generationSchema),
|
onSuccess: async (generationId) => {
|
||||||
defaultValues: {
|
setIsExpanded(false);
|
||||||
text: '',
|
// If on stories route and a story is selected, add generation to story
|
||||||
language: 'en',
|
if (isStoriesRoute && selectedStoryId && generationId) {
|
||||||
modelSize: '1.7B',
|
try {
|
||||||
|
await addStoryItem.mutateAsync({
|
||||||
|
storyId: selectedStoryId,
|
||||||
|
data: { generation_id: generationId },
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: 'Added to story',
|
||||||
|
description: `Generation added to "${currentStory?.name || 'story'}"`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to add to story',
|
||||||
|
description:
|
||||||
|
error instanceof Error ? error.message : 'Could not add generation to story',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,70 +105,86 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps)
|
|||||||
};
|
};
|
||||||
}, [isExpanded]);
|
}, [isExpanded]);
|
||||||
|
|
||||||
async function onSubmit(data: GenerationFormValues) {
|
// Set first voice as default if none selected
|
||||||
if (!selectedProfileId) {
|
useEffect(() => {
|
||||||
toast({
|
if (!selectedProfileId && profiles && profiles.length > 0) {
|
||||||
title: 'No profile selected',
|
setSelectedProfileId(profiles[0].id);
|
||||||
description: 'Please select a voice profile from the cards above.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}, [selectedProfileId, profiles, setSelectedProfileId]);
|
||||||
|
|
||||||
try {
|
|
||||||
setIsGenerating(true);
|
|
||||||
|
|
||||||
const modelName = `qwen-tts-${data.modelSize}`;
|
// Auto-resize textarea based on content (only when expanded)
|
||||||
const displayName = data.modelSize === '1.7B' ? 'Qwen TTS 1.7B' : 'Qwen TTS 0.6B';
|
useEffect(() => {
|
||||||
|
if (!isExpanded) {
|
||||||
try {
|
// Reset textarea height after collapse animation completes
|
||||||
const modelStatus = await apiClient.getModelStatus();
|
const timeoutId = setTimeout(() => {
|
||||||
const model = modelStatus.models.find((m) => m.model_name === modelName);
|
const textarea = textareaRef.current;
|
||||||
|
if (textarea) {
|
||||||
if (model && !model.downloaded) {
|
textarea.style.height = '32px';
|
||||||
setDownloadingModelName(modelName);
|
textarea.style.overflowY = 'hidden';
|
||||||
setDownloadingDisplayName(displayName);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
}, 200); // Wait for animation to complete
|
||||||
console.error('Failed to check model status:', error);
|
return () => clearTimeout(timeoutId);
|
||||||
}
|
|
||||||
|
|
||||||
const result = await generation.mutateAsync({
|
|
||||||
profile_id: selectedProfileId,
|
|
||||||
text: data.text,
|
|
||||||
language: data.language,
|
|
||||||
model_size: data.modelSize,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Generation complete!',
|
|
||||||
description: `Audio generated (${result.duration.toFixed(2)}s)`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const audioUrl = apiClient.getAudioUrl(result.id);
|
|
||||||
setAudio(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
|
|
||||||
|
|
||||||
form.reset();
|
|
||||||
setIsExpanded(false);
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: 'Generation failed',
|
|
||||||
description: error instanceof Error ? error.message : 'Failed to generate audio',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsGenerating(false);
|
|
||||||
setDownloadingModelName(null);
|
|
||||||
setDownloadingDisplayName(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const adjustHeight = () => {
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
const scrollHeight = textarea.scrollHeight;
|
||||||
|
const minHeight = 100; // Expanded minimum
|
||||||
|
const maxHeight = 300; // Max height in pixels
|
||||||
|
const targetHeight = Math.max(minHeight, Math.min(scrollHeight, maxHeight));
|
||||||
|
textarea.style.height = `${targetHeight}px`;
|
||||||
|
|
||||||
|
// Show scrollbar if content exceeds max height
|
||||||
|
if (scrollHeight > maxHeight) {
|
||||||
|
textarea.style.overflowY = 'auto';
|
||||||
|
} else {
|
||||||
|
textarea.style.overflowY = 'hidden';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Small delay to let framer animation complete
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
adjustHeight();
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
// Adjust on mount and when value changes
|
||||||
|
adjustHeight();
|
||||||
|
|
||||||
|
// Watch for input changes
|
||||||
|
textarea.addEventListener('input', adjustHeight);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
textarea.removeEventListener('input', adjustHeight);
|
||||||
|
};
|
||||||
|
}, [isExpanded]);
|
||||||
|
|
||||||
|
async function onSubmit(data: Parameters<typeof handleSubmit>[0]) {
|
||||||
|
await handleSubmit(data, selectedProfileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="fixed left-[calc(5rem+2rem)] right-auto w-[calc((100%-5rem-4rem)/2-1rem)]"
|
className={cn(
|
||||||
|
'fixed right-auto',
|
||||||
|
isStoriesRoute
|
||||||
|
? // Position aligned with story list: after sidebar + padding, width 360px
|
||||||
|
'left-[calc(5rem+2rem)] w-[360px]'
|
||||||
|
: 'left-[calc(5rem+2rem)] w-[calc((100%-5rem-4rem)/2-1rem)]',
|
||||||
|
)}
|
||||||
style={{
|
style={{
|
||||||
bottom: isPlayerOpen ? 'calc(7rem + 1.5rem)' : '1.5rem',
|
// On stories route: offset by track editor height when visible
|
||||||
|
// On other routes: offset by audio player height when visible
|
||||||
|
bottom: hasTrackEditor
|
||||||
|
? `${trackEditorHeight + 24}px`
|
||||||
|
: isPlayerOpen
|
||||||
|
? 'calc(7rem + 1.5rem)'
|
||||||
|
: '1.5rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -167,51 +195,145 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps)
|
|||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<motion.div
|
<motion.div
|
||||||
className="flex-1"
|
className={cn('flex-1', isExpanded && 'mr-12')}
|
||||||
// animate={{ marginBottom: isExpanded ? '0.75rem' : '0' }}
|
|
||||||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||||
>
|
>
|
||||||
<FormField
|
{/* Text field - hidden when in instruct mode */}
|
||||||
control={form.control}
|
<div style={{ display: isInstructMode ? 'none' : 'block' }}>
|
||||||
name="text"
|
<FormField
|
||||||
render={({ field }) => (
|
control={form.control}
|
||||||
<FormItem>
|
name="text"
|
||||||
<FormControl>
|
render={({ field }) => (
|
||||||
<Textarea
|
<FormItem>
|
||||||
placeholder={
|
<FormControl>
|
||||||
selectedProfile
|
<motion.div
|
||||||
? `Generate speech using ${selectedProfile.name}...`
|
animate={{
|
||||||
: 'Select a voice profile above...'
|
height: isExpanded ? 'auto' : '32px',
|
||||||
}
|
}}
|
||||||
className="resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm placeholder:text-muted-foreground/60 overflow-hidden transition-all"
|
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||||
style={{
|
style={{ overflow: 'hidden' }}
|
||||||
minHeight: isExpanded ? '100px' : '32px',
|
>
|
||||||
height: isExpanded ? '100px' : '32px',
|
<Textarea
|
||||||
}}
|
{...field}
|
||||||
disabled={!selectedProfileId}
|
ref={(node: HTMLTextAreaElement | null) => {
|
||||||
onClick={() => setIsExpanded(true)}
|
// Store ref for auto-resize (only for active field)
|
||||||
onFocus={() => setIsExpanded(true)}
|
if (!isInstructMode) {
|
||||||
{...field}
|
textareaRef.current = node;
|
||||||
/>
|
}
|
||||||
</FormControl>
|
// Forward ref to react-hook-form
|
||||||
<FormMessage className="text-xs" />
|
if (typeof field.ref === 'function') {
|
||||||
</FormItem>
|
field.ref(node);
|
||||||
)}
|
}
|
||||||
/>
|
}}
|
||||||
|
placeholder={
|
||||||
|
isStoriesRoute && currentStory
|
||||||
|
? `Generate speech for "${currentStory.name}"...`
|
||||||
|
: selectedProfile
|
||||||
|
? `Generate speech using ${selectedProfile.name}...`
|
||||||
|
: 'Select a voice profile above...'
|
||||||
|
}
|
||||||
|
className="resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm placeholder:text-muted-foreground/60 w-full"
|
||||||
|
style={{
|
||||||
|
minHeight: isExpanded ? '100px' : '32px',
|
||||||
|
maxHeight: '300px',
|
||||||
|
}}
|
||||||
|
disabled={!selectedProfileId}
|
||||||
|
onClick={() => setIsExpanded(true)}
|
||||||
|
onFocus={() => setIsExpanded(true)}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage className="text-xs" />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Instruct field - hidden when in text mode */}
|
||||||
|
<div style={{ display: isInstructMode ? 'block' : 'none' }}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="instruct"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
height: isExpanded ? 'auto' : '32px',
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||||
|
style={{ overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
{...field}
|
||||||
|
ref={(node: HTMLTextAreaElement | null) => {
|
||||||
|
// Store ref for auto-resize (only for active field)
|
||||||
|
if (isInstructMode) {
|
||||||
|
textareaRef.current = node;
|
||||||
|
}
|
||||||
|
// Forward ref to react-hook-form
|
||||||
|
if (typeof field.ref === 'function') {
|
||||||
|
field.ref(node);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Add delivery instructions..."
|
||||||
|
className="resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm placeholder:text-muted-foreground/60 w-full"
|
||||||
|
style={{
|
||||||
|
minHeight: isExpanded ? '100px' : '32px',
|
||||||
|
maxHeight: '300px',
|
||||||
|
}}
|
||||||
|
disabled={!selectedProfileId}
|
||||||
|
onClick={() => setIsExpanded(true)}
|
||||||
|
onFocus={() => setIsExpanded(true)}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage className="text-xs" />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<Button
|
<div className="relative shrink-0">
|
||||||
type="submit"
|
<Button
|
||||||
disabled={generation.isPending || !selectedProfileId}
|
type="submit"
|
||||||
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 shrink-0 transition-all duration-200"
|
disabled={isPending || !selectedProfileId}
|
||||||
size="icon"
|
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 transition-all duration-200"
|
||||||
>
|
size="icon"
|
||||||
{generation.isPending ? (
|
>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
{isPending ? (
|
||||||
) : (
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
<Sparkles className="h-4 w-4" />
|
) : (
|
||||||
)}
|
<Sparkles className="h-4 w-4" />
|
||||||
</Button>
|
)}
|
||||||
|
</Button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{isExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="absolute top-0 right-[calc(100%+0.5rem)]"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setIsInstructMode(!isInstructMode)}
|
||||||
|
className={cn(
|
||||||
|
'h-10 w-10 rounded-full transition-all duration-200',
|
||||||
|
isInstructMode
|
||||||
|
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
|
||||||
|
: 'bg-card border border-border hover:bg-background/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -223,11 +345,31 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps)
|
|||||||
className=" mt-3"
|
className=" mt-3"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{showVoiceSelector && (
|
||||||
|
<div className="flex-1">
|
||||||
|
<Select
|
||||||
|
value={selectedProfileId || ''}
|
||||||
|
onValueChange={(value) => setSelectedProfileId(value || null)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all w-full">
|
||||||
|
<SelectValue placeholder="Select a voice..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{profiles?.map((profile) => (
|
||||||
|
<SelectItem key={profile.id} value={profile.id} className="text-xs">
|
||||||
|
{profile.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="language"
|
name="language"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<FormItem className="flex-1 space-y-0">
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
||||||
@@ -251,7 +393,7 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps)
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="modelSize"
|
name="modelSize"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<FormItem className="flex-1 space-y-0">
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { Loader2, Mic } from 'lucide-react';
|
import { Loader2, Mic } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import * as z from 'zod';
|
|
||||||
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';
|
||||||
import {
|
import {
|
||||||
@@ -23,118 +19,19 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { LANGUAGE_OPTIONS } from '@/lib/constants/languages';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
||||||
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
|
|
||||||
import { useGeneration } from '@/lib/hooks/useGeneration';
|
|
||||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
|
||||||
import { useProfile } from '@/lib/hooks/useProfiles';
|
import { useProfile } from '@/lib/hooks/useProfiles';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
|
||||||
const generationSchema = z.object({
|
|
||||||
text: z.string().min(1, 'Text is required').max(5000),
|
|
||||||
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
|
||||||
seed: z.number().int().optional(),
|
|
||||||
modelSize: z.enum(['1.7B', '0.6B']).optional(),
|
|
||||||
instruct: z.string().max(500).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type GenerationFormValues = z.infer<typeof generationSchema>;
|
|
||||||
|
|
||||||
export function GenerationForm() {
|
export function GenerationForm() {
|
||||||
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||||
const { data: selectedProfile } = useProfile(selectedProfileId || '');
|
const { data: selectedProfile } = useProfile(selectedProfileId || '');
|
||||||
const generation = useGeneration();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const setAudio = usePlayerStore((state) => state.setAudio);
|
|
||||||
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
|
|
||||||
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
|
|
||||||
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Use the download toast hook to show progress when model is downloading
|
const { form, handleSubmit, isPending } = useGenerationForm();
|
||||||
useModelDownloadToast({
|
|
||||||
modelName: downloadingModelName || '',
|
|
||||||
displayName: downloadingDisplayName || '',
|
|
||||||
enabled: !!downloadingModelName,
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm<GenerationFormValues>({
|
async function onSubmit(data: Parameters<typeof handleSubmit>[0]) {
|
||||||
resolver: zodResolver(generationSchema),
|
await handleSubmit(data, selectedProfileId);
|
||||||
defaultValues: {
|
|
||||||
text: '',
|
|
||||||
language: 'en',
|
|
||||||
seed: undefined,
|
|
||||||
modelSize: '1.7B',
|
|
||||||
instruct: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function onSubmit(data: GenerationFormValues) {
|
|
||||||
if (!selectedProfileId) {
|
|
||||||
toast({
|
|
||||||
title: 'No profile selected',
|
|
||||||
description: 'Please select a voice profile from the cards above.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsGenerating(true);
|
|
||||||
|
|
||||||
// Determine model name and display name
|
|
||||||
const modelName = `qwen-tts-${data.modelSize}`;
|
|
||||||
const displayName = data.modelSize === '1.7B' ? 'Qwen TTS 1.7B' : 'Qwen TTS 0.6B';
|
|
||||||
|
|
||||||
// Check if model is downloaded before starting generation
|
|
||||||
try {
|
|
||||||
const modelStatus = await apiClient.getModelStatus();
|
|
||||||
const model = modelStatus.models.find((m) => m.model_name === modelName);
|
|
||||||
|
|
||||||
if (model && !model.downloaded) {
|
|
||||||
// Model is not downloaded, enable download toast
|
|
||||||
setDownloadingModelName(modelName);
|
|
||||||
setDownloadingDisplayName(displayName);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// If status check fails, continue anyway - generation will handle it
|
|
||||||
console.error('Failed to check model status:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Proceed with generation (which will trigger download if needed)
|
|
||||||
const result = await generation.mutateAsync({
|
|
||||||
profile_id: selectedProfileId,
|
|
||||||
text: data.text,
|
|
||||||
language: data.language,
|
|
||||||
seed: data.seed,
|
|
||||||
model_size: data.modelSize,
|
|
||||||
instruct: data.instruct || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Generation complete!',
|
|
||||||
description: `Audio generated (${result.duration.toFixed(2)}s)`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Autoplay the generated audio
|
|
||||||
const audioUrl = apiClient.getAudioUrl(result.id);
|
|
||||||
setAudio(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
|
|
||||||
|
|
||||||
form.reset();
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: 'Generation failed',
|
|
||||||
description: error instanceof Error ? error.message : 'Failed to generate audio',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsGenerating(false);
|
|
||||||
// Clear download state after generation completes
|
|
||||||
setDownloadingModelName(null);
|
|
||||||
setDownloadingDisplayName(null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -276,9 +173,9 @@ export function GenerationForm() {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={generation.isPending || !selectedProfileId}
|
disabled={isPending || !selectedProfileId}
|
||||||
>
|
>
|
||||||
{generation.isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Generating...
|
Generating...
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
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 {
|
import {
|
||||||
@@ -41,6 +42,7 @@ export function HistoryTable() {
|
|||||||
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 limit = 20;
|
const limit = 20;
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { data: historyData, isLoading } = useHistory({
|
const { data: historyData, isLoading } = useHistory({
|
||||||
limit,
|
limit,
|
||||||
@@ -51,7 +53,7 @@ export function HistoryTable() {
|
|||||||
const exportGeneration = useExportGeneration();
|
const exportGeneration = useExportGeneration();
|
||||||
const exportGenerationAudio = useExportGenerationAudio();
|
const exportGenerationAudio = useExportGenerationAudio();
|
||||||
const importGeneration = useImportGeneration();
|
const importGeneration = useImportGeneration();
|
||||||
const setAudio = usePlayerStore((state) => state.setAudio);
|
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
|
||||||
const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio);
|
const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio);
|
||||||
const currentAudioId = usePlayerStore((state) => state.audioId);
|
const currentAudioId = usePlayerStore((state) => state.audioId);
|
||||||
const isPlaying = usePlayerStore((state) => state.isPlaying);
|
const isPlaying = usePlayerStore((state) => state.isPlaying);
|
||||||
@@ -75,9 +77,9 @@ export function HistoryTable() {
|
|||||||
if (currentAudioId === audioId) {
|
if (currentAudioId === audioId) {
|
||||||
restartCurrentAudio();
|
restartCurrentAudio();
|
||||||
} else {
|
} else {
|
||||||
// Otherwise, load the new audio
|
// Otherwise, load the new audio and auto-play it
|
||||||
const audioUrl = apiClient.getAudioUrl(audioId);
|
const audioUrl = apiClient.getAudioUrl(audioId);
|
||||||
setAudio(audioUrl, audioId, profileId, text.substring(0, 50));
|
setAudioWithAutoPlay(audioUrl, audioId, profileId, text.substring(0, 50));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,7 +88,11 @@ export function HistoryTable() {
|
|||||||
{ generationId, text },
|
{ generationId, text },
|
||||||
{
|
{
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
alert(`Failed to download audio: ${error.message}`);
|
toast({
|
||||||
|
title: 'Failed to download audio',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -97,7 +103,11 @@ export function HistoryTable() {
|
|||||||
{ generationId, text },
|
{ generationId, text },
|
||||||
{
|
{
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
alert(`Failed to export generation: ${error.message}`);
|
toast({
|
||||||
|
title: 'Failed to export generation',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -112,7 +122,11 @@ export function HistoryTable() {
|
|||||||
if (file) {
|
if (file) {
|
||||||
// Validate file extension
|
// Validate file extension
|
||||||
if (!file.name.endsWith('.voicebox.zip')) {
|
if (!file.name.endsWith('.voicebox.zip')) {
|
||||||
alert('Please select a valid .voicebox.zip file');
|
toast({
|
||||||
|
title: 'Invalid file type',
|
||||||
|
description: 'Please select a valid .voicebox.zip file',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelectedFile(file);
|
setSelectedFile(file);
|
||||||
@@ -129,10 +143,17 @@ export function HistoryTable() {
|
|||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = '';
|
fileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
alert(data.message || 'Generation imported successfully');
|
toast({
|
||||||
|
title: 'Generation imported',
|
||||||
|
description: data.message || 'Generation imported successfully',
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
alert(`Failed to import generation: ${error.message}`);
|
toast({
|
||||||
|
title: 'Failed to import generation',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -148,23 +169,6 @@ export function HistoryTable() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0 relative">
|
<div className="flex flex-col h-full min-h-0 relative">
|
||||||
{/* <div className="flex justify-between items-center mb-4 shrink-0">
|
|
||||||
<h2 className="text-2xl font-bold">History</h2>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" onClick={handleImportClick}>
|
|
||||||
<Upload className="mr-2 h-4 w-4" />
|
|
||||||
Import Generation
|
|
||||||
</Button>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".voicebox.zip"
|
|
||||||
onChange={handleFileChange}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div> */}
|
|
||||||
|
|
||||||
{history.length === 0 ? (
|
{history.length === 0 ? (
|
||||||
<div className="text-center py-12 px-5 border-2 border-dashed mb-5 border-muted rounded-md text-muted-foreground flex-1 flex items-center justify-center">
|
<div className="text-center py-12 px-5 border-2 border-dashed mb-5 border-muted rounded-md text-muted-foreground flex-1 flex items-center justify-center">
|
||||||
No voice generations, yet...
|
No voice generations, yet...
|
||||||
@@ -229,7 +233,11 @@ export function HistoryTable() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Far right - Ellipsis actions */}
|
{/* Far right - Ellipsis actions */}
|
||||||
<div className="w-10 shrink-0 flex justify-end">
|
<div
|
||||||
|
className="w-10 shrink-0 flex justify-end"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -237,7 +245,6 @@ export function HistoryTable() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
aria-label="Actions"
|
aria-label="Actions"
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
|
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
|
||||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import { useImportProfile } from '@/lib/hooks/useProfiles';
|
import { useImportProfile } from '@/lib/hooks/useProfiles';
|
||||||
@@ -27,6 +28,7 @@ export function MainEditor() {
|
|||||||
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 { toast } = useToast();
|
||||||
|
|
||||||
const handleImportClick = () => {
|
const handleImportClick = () => {
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
@@ -36,7 +38,11 @@ export function MainEditor() {
|
|||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
if (!file.name.endsWith('.voicebox.zip')) {
|
if (!file.name.endsWith('.voicebox.zip')) {
|
||||||
alert('Please select a valid .voicebox.zip file');
|
toast({
|
||||||
|
title: 'Invalid file type',
|
||||||
|
description: 'Please select a valid .voicebox.zip file',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelectedFile(file);
|
setSelectedFile(file);
|
||||||
@@ -53,9 +59,17 @@ export function MainEditor() {
|
|||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = '';
|
fileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
|
toast({
|
||||||
|
title: 'Profile imported',
|
||||||
|
description: 'Voice profile imported successfully',
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
alert(`Failed to import profile: ${error.message}`);
|
toast({
|
||||||
|
title: 'Failed to import profile',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -102,15 +116,9 @@ export function MainEditor() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{/* Profiles - Top Left */}
|
|
||||||
<div className="shrink-0 flex flex-col">
|
<div className="shrink-0 flex flex-col">
|
||||||
<ProfileList />
|
<ProfileList />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Generator - Bottom Left */}
|
|
||||||
{/* <div className="shrink-0">
|
|
||||||
<GenerationForm />
|
|
||||||
</div> */}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,11 +12,10 @@ interface ModelProgressProps {
|
|||||||
|
|
||||||
export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
||||||
const [progress, setProgress] = useState<ModelProgressType | null>(null);
|
const [progress, setProgress] = useState<ModelProgressType | null>(null);
|
||||||
const [isSubscribed, setIsSubscribed] = useState(false);
|
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!serverUrl || isSubscribed) return;
|
if (!serverUrl) return;
|
||||||
|
|
||||||
// Subscribe to progress updates via Server-Sent Events
|
// Subscribe to progress updates via Server-Sent Events
|
||||||
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
|
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
|
||||||
@@ -29,7 +28,6 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
|||||||
// Close connection if complete or error
|
// Close connection if complete or error
|
||||||
if (data.status === 'complete' || data.status === 'error') {
|
if (data.status === 'complete' || data.status === 'error') {
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
setIsSubscribed(false);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing progress event:', error);
|
console.error('Error parsing progress event:', error);
|
||||||
@@ -39,16 +37,12 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
|||||||
eventSource.onerror = (error) => {
|
eventSource.onerror = (error) => {
|
||||||
console.error('SSE error:', error);
|
console.error('SSE error:', error);
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
setIsSubscribed(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setIsSubscribed(true);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
setIsSubscribed(false);
|
|
||||||
};
|
};
|
||||||
}, [serverUrl, modelName, isSubscribed]);
|
}, [serverUrl, modelName]);
|
||||||
|
|
||||||
// Don't render if no progress or if complete/error and some time has passed
|
// Don't render if no progress or if complete/error and some time has passed
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getVersion } from '@tauri-apps/api/app';
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
import { RefreshCw, Download, AlertCircle } 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';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -93,7 +93,7 @@ export function UpdateStatus() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{status.readyToInstall && (
|
{status.readyToInstall && (
|
||||||
<div className="space-y-3 p-4 border rounded-lg bg-green-500/10 border-green-500/20">
|
<div className="space-y-3 p-4 border rounded-lg bg-accent/30 border-accent/50">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold">Update Ready to Install</div>
|
<div className="font-semibold">Update Ready to Install</div>
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
import { Box, Loader2, Mic, Server, Speaker, Volume2 } from 'lucide-react';
|
import { Link, useMatchRoute } from '@tanstack/react-router';
|
||||||
|
import { Box, BookOpen, Loader2, Mic, Server, Speaker, Volume2 } from 'lucide-react';
|
||||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
activeTab: string;
|
|
||||||
onTabChange: (tab: string) => void;
|
|
||||||
isMacOS?: boolean;
|
isMacOS?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'main', icon: Volume2, label: 'Generate' },
|
{ id: 'main', path: '/', icon: Volume2, label: 'Generate' },
|
||||||
{ id: 'voices', icon: Mic, label: 'Voices' },
|
{ id: 'stories', path: '/stories', icon: BookOpen, label: 'Stories' },
|
||||||
{ id: 'audio', icon: Speaker, label: 'Audio' },
|
{ id: 'voices', path: '/voices', icon: Mic, label: 'Voices' },
|
||||||
{ id: 'models', icon: Box, label: 'Models' },
|
{ id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' },
|
||||||
{ id: 'server', icon: Server, label: 'Server' },
|
{ id: 'models', path: '/models', icon: Box, label: 'Models' },
|
||||||
|
{ id: 'server', path: '/server', icon: Server, label: 'Server' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar({ activeTab, onTabChange, isMacOS }: SidebarProps) {
|
export function Sidebar({ isMacOS }: SidebarProps) {
|
||||||
const isGenerating = useGenerationStore((state) => state.isGenerating);
|
const isGenerating = useGenerationStore((state) => state.isGenerating);
|
||||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||||
const isPlayerVisible = !!audioUrl;
|
const isPlayerVisible = !!audioUrl;
|
||||||
|
const matchRoute = useMatchRoute();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -39,13 +40,16 @@ export function Sidebar({ activeTab, onTabChange, isMacOS }: SidebarProps) {
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
const isActive = activeTab === tab.id;
|
// For index route, use exact match; for others, use default matching
|
||||||
|
const isActive =
|
||||||
|
tab.path === '/'
|
||||||
|
? matchRoute({ to: '/', exact: true })
|
||||||
|
: matchRoute({ to: tab.path });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Link
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
type="button"
|
to={tab.path}
|
||||||
onClick={() => onTabChange(tab.id)}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-12 h-12 rounded-full flex items-center justify-center transition-all duration-200',
|
'w-12 h-12 rounded-full flex items-center justify-center transition-all duration-200',
|
||||||
'hover:bg-muted/50',
|
'hover:bg-muted/50',
|
||||||
@@ -55,7 +59,7 @@ export function Sidebar({ activeTab, onTabChange, isMacOS }: SidebarProps) {
|
|||||||
aria-label={tab.label}
|
aria-label={tab.label}
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5" />
|
<Icon className="h-5 w-5" />
|
||||||
</button>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
||||||
|
import { StoryContent } from './StoryContent';
|
||||||
|
import { StoryList } from './StoryList';
|
||||||
|
|
||||||
|
export function StoriesTab() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
||||||
|
{/* Main content area */}
|
||||||
|
<div className="flex-1 min-h-0 flex gap-6 overflow-hidden relative">
|
||||||
|
{/* Left Column - Story List */}
|
||||||
|
<div className="flex flex-col min-h-0 overflow-hidden w-full max-w-[360px] shrink-0">
|
||||||
|
<StoryList />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Story Content */}
|
||||||
|
<div className="flex flex-col min-h-0 overflow-hidden flex-1">
|
||||||
|
<StoryContent />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Generate Box - position is managed via storyStore.trackEditorHeight */}
|
||||||
|
<FloatingGenerateBox showVoiceSelector />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { GripVertical, Mic, MoreHorizontal, Play, Trash2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import type { StoryItemDetail } from '@/lib/api/types';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
|
|
||||||
|
interface StoryChatItemProps {
|
||||||
|
item: StoryItemDetail;
|
||||||
|
storyId: string;
|
||||||
|
index: number;
|
||||||
|
onRemove: () => void;
|
||||||
|
currentTimeMs: number;
|
||||||
|
isPlaying: boolean;
|
||||||
|
dragHandleProps?: React.HTMLAttributes<HTMLButtonElement>;
|
||||||
|
isDragging?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StoryChatItem({
|
||||||
|
item,
|
||||||
|
onRemove,
|
||||||
|
currentTimeMs,
|
||||||
|
isPlaying,
|
||||||
|
dragHandleProps,
|
||||||
|
isDragging,
|
||||||
|
}: StoryChatItemProps) {
|
||||||
|
const seek = useStoryStore((state) => state.seek);
|
||||||
|
|
||||||
|
// Check if this item is currently playing based on timecode
|
||||||
|
const itemStartMs = item.start_time_ms;
|
||||||
|
const itemEndMs = item.start_time_ms + item.duration * 1000;
|
||||||
|
const isCurrentlyPlaying = isPlaying && currentTimeMs >= itemStartMs && currentTimeMs < itemEndMs;
|
||||||
|
|
||||||
|
const handlePlay = () => {
|
||||||
|
// Seek to the start of this item
|
||||||
|
seek(itemStartMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (ms: number): string => {
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
const milliseconds = Math.floor((ms % 1000) / 100);
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}.${milliseconds}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-3 p-4 rounded-lg border transition-colors',
|
||||||
|
isCurrentlyPlaying && 'bg-muted/70 border-primary',
|
||||||
|
!isCurrentlyPlaying && 'hover:bg-muted/50',
|
||||||
|
isDragging && 'opacity-50 shadow-lg',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Drag Handle */}
|
||||||
|
{dragHandleProps && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 cursor-grab active:cursor-grabbing touch-none text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
{...dragHandleProps}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Voice Icon */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center">
|
||||||
|
<Mic className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="font-medium text-sm">{item.profile_name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{item.language}</span>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums ml-auto">
|
||||||
|
{formatTime(itemStartMs)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={item.text}
|
||||||
|
className="flex-1 resize-none text-sm text-muted-foreground select-text bg-card cursor-text"
|
||||||
|
readOnly
|
||||||
|
onDoubleClick={handlePlay}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Actions">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={handlePlay}>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
Play from here
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={onRemove} className="text-destructive focus:text-destructive">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Remove from Story
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sortable wrapper component
|
||||||
|
export function SortableStoryChatItem(props: Omit<StoryChatItemProps, 'dragHandleProps' | 'isDragging'>) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: props.item.generation_id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style} {...attributes}>
|
||||||
|
<StoryChatItem
|
||||||
|
{...props}
|
||||||
|
dragHandleProps={listeners}
|
||||||
|
isDragging={isDragging}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
type DragEndEvent,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { Download, Plus } from 'lucide-react';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { useHistory } from '@/lib/hooks/useHistory';
|
||||||
|
import {
|
||||||
|
useAddStoryItem,
|
||||||
|
useExportStoryAudio,
|
||||||
|
useRemoveStoryItem,
|
||||||
|
useReorderStoryItems,
|
||||||
|
useStory,
|
||||||
|
} from '@/lib/hooks/useStories';
|
||||||
|
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
|
||||||
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
|
import { SortableStoryChatItem } from './StoryChatItem';
|
||||||
|
|
||||||
|
export function StoryContent() {
|
||||||
|
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||||
|
const { data: story, isLoading } = useStory(selectedStoryId);
|
||||||
|
const removeItem = useRemoveStoryItem();
|
||||||
|
const reorderItems = useReorderStoryItems();
|
||||||
|
const exportAudio = useExportStoryAudio();
|
||||||
|
const addStoryItem = useAddStoryItem();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Add generation popover state
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||||
|
const { data: historyData } = useHistory();
|
||||||
|
|
||||||
|
// Filter generations not in story and matching search
|
||||||
|
const availableGenerations = useMemo(() => {
|
||||||
|
if (!historyData?.items || !story) return [];
|
||||||
|
const storyGenerationIds = new Set(story.items.map((i) => i.generation_id));
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return historyData.items.filter(
|
||||||
|
(gen) =>
|
||||||
|
!storyGenerationIds.has(gen.id) &&
|
||||||
|
(gen.text.toLowerCase().includes(query) ||
|
||||||
|
gen.profile_name.toLowerCase().includes(query)),
|
||||||
|
);
|
||||||
|
}, [historyData, story, searchQuery]);
|
||||||
|
|
||||||
|
// Get track editor height from store for dynamic padding
|
||||||
|
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||||
|
|
||||||
|
// Track editor is shown when story has items
|
||||||
|
const hasBottomBar = story && story.items.length > 0;
|
||||||
|
|
||||||
|
// Calculate dynamic bottom padding: track editor + gap
|
||||||
|
const bottomPadding = hasBottomBar ? trackEditorHeight + 24 : 0;
|
||||||
|
|
||||||
|
// Drag and drop sensors
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Playback state (for auto-scroll and item highlighting)
|
||||||
|
const isPlaying = useStoryStore((state) => state.isPlaying);
|
||||||
|
const currentTimeMs = useStoryStore((state) => state.currentTimeMs);
|
||||||
|
const playbackStoryId = useStoryStore((state) => state.playbackStoryId);
|
||||||
|
|
||||||
|
// Refs for auto-scrolling to playing item
|
||||||
|
const itemRefsMap = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
|
const lastScrolledItemRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Use playback hook
|
||||||
|
useStoryPlayback(story?.items);
|
||||||
|
|
||||||
|
// Sort items by start_time_ms
|
||||||
|
const sortedItems = useMemo(() => {
|
||||||
|
if (!story?.items) return [];
|
||||||
|
return [...story.items].sort((a, b) => a.start_time_ms - b.start_time_ms);
|
||||||
|
}, [story?.items]);
|
||||||
|
|
||||||
|
// Find the currently playing item based on timecode
|
||||||
|
const currentlyPlayingItemId = useMemo(() => {
|
||||||
|
if (!isPlaying || playbackStoryId !== story?.id || !sortedItems.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const playingItem = sortedItems.find((item) => {
|
||||||
|
const itemStart = item.start_time_ms;
|
||||||
|
const itemEnd = item.start_time_ms + item.duration * 1000;
|
||||||
|
return currentTimeMs >= itemStart && currentTimeMs < itemEnd;
|
||||||
|
});
|
||||||
|
return playingItem?.generation_id ?? null;
|
||||||
|
}, [isPlaying, playbackStoryId, story?.id, sortedItems, currentTimeMs]);
|
||||||
|
|
||||||
|
// Auto-scroll to the currently playing item
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentlyPlayingItemId || currentlyPlayingItemId === lastScrolledItemRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = itemRefsMap.current.get(currentlyPlayingItemId);
|
||||||
|
if (element && scrollRef.current) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
lastScrolledItemRef.current = currentlyPlayingItemId;
|
||||||
|
}
|
||||||
|
}, [currentlyPlayingItemId]);
|
||||||
|
|
||||||
|
// Reset last scrolled item when playback stops
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying) {
|
||||||
|
lastScrolledItemRef.current = null;
|
||||||
|
}
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
const handleRemoveItem = (itemId: string) => {
|
||||||
|
if (!story) return;
|
||||||
|
|
||||||
|
removeItem.mutate(
|
||||||
|
{
|
||||||
|
storyId: story.id,
|
||||||
|
itemId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to remove item',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (!story || !over || active.id === over.id) return;
|
||||||
|
|
||||||
|
const oldIndex = sortedItems.findIndex((item) => item.generation_id === active.id);
|
||||||
|
const newIndex = sortedItems.findIndex((item) => item.generation_id === over.id);
|
||||||
|
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return;
|
||||||
|
|
||||||
|
// Calculate the new order
|
||||||
|
const newOrder = arrayMove(sortedItems, oldIndex, newIndex);
|
||||||
|
const generationIds = newOrder.map((item) => item.generation_id);
|
||||||
|
|
||||||
|
// Send reorder request to backend
|
||||||
|
reorderItems.mutate(
|
||||||
|
{
|
||||||
|
storyId: story.id,
|
||||||
|
data: { generation_ids: generationIds },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to reorder items',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportAudio = () => {
|
||||||
|
if (!story) return;
|
||||||
|
|
||||||
|
exportAudio.mutate(
|
||||||
|
{
|
||||||
|
storyId: story.id,
|
||||||
|
storyName: story.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to export audio',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddGeneration = (generationId: string) => {
|
||||||
|
if (!story) return;
|
||||||
|
|
||||||
|
addStoryItem.mutate(
|
||||||
|
{
|
||||||
|
storyId: story.id,
|
||||||
|
data: { generation_id: generationId },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsAddOpen(false);
|
||||||
|
setSearchQuery('');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to add generation',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!selectedStoryId) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-lg font-medium mb-2">Select a story</p>
|
||||||
|
<p className="text-sm">Choose a story from the list to view its content</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-muted-foreground">Loading story...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!story) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-lg font-medium mb-2">Story not found</p>
|
||||||
|
<p className="text-sm">The selected story could not be loaded</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4 px-1">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold">{story.name}</h2>
|
||||||
|
{story.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{story.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Popover open={isAddOpen} onOpenChange={setIsAddOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80 p-0" align="end">
|
||||||
|
<div className="p-2 border-b">
|
||||||
|
<Input
|
||||||
|
placeholder="Search by name or transcript..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-60 overflow-y-auto">
|
||||||
|
{availableGenerations.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
|
{searchQuery
|
||||||
|
? 'No matching generations found'
|
||||||
|
: 'No available generations'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
availableGenerations.map((gen) => (
|
||||||
|
<button
|
||||||
|
key={gen.id}
|
||||||
|
type="button"
|
||||||
|
className="w-full text-left px-3 py-2 hover:bg-muted transition-colors border-b last:border-b-0"
|
||||||
|
onClick={() => handleAddGeneration(gen.id)}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-sm">{gen.profile_name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{gen.text.length > 50 ? `${gen.text.substring(0, 50)}...` : gen.text}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
{story.items.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleExportAudio}
|
||||||
|
disabled={exportAudio.isPending}
|
||||||
|
>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Export Audio
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="flex-1 min-h-0 overflow-y-auto space-y-3"
|
||||||
|
style={{ paddingBottom: bottomPadding > 0 ? `${bottomPadding}px` : undefined }}
|
||||||
|
>
|
||||||
|
{sortedItems.length === 0 ? (
|
||||||
|
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-md text-muted-foreground">
|
||||||
|
<p className="text-sm">No items in this story</p>
|
||||||
|
<p className="text-xs mt-2">Generate speech using the box below to add items</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={sortedItems.map((item) => item.generation_id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sortedItems.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) {
|
||||||
|
itemRefsMap.current.set(item.generation_id, el);
|
||||||
|
} else {
|
||||||
|
itemRefsMap.current.delete(item.generation_id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SortableStoryChatItem
|
||||||
|
item={item}
|
||||||
|
storyId={story.id}
|
||||||
|
index={index}
|
||||||
|
onRemove={() => handleRemoveItem(item.id)}
|
||||||
|
currentTimeMs={currentTimeMs}
|
||||||
|
isPlaying={isPlaying && playbackStoryId === story.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import { Plus, BookOpen, MoreHorizontal, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import {
|
||||||
|
useStories,
|
||||||
|
useCreateStory,
|
||||||
|
useUpdateStory,
|
||||||
|
useDeleteStory,
|
||||||
|
} from '@/lib/hooks/useStories';
|
||||||
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { formatDate } from '@/lib/utils/format';
|
||||||
|
|
||||||
|
export function StoryList() {
|
||||||
|
const { data: stories, isLoading } = useStories();
|
||||||
|
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||||
|
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
|
||||||
|
const createStory = useCreateStory();
|
||||||
|
const updateStory = useUpdateStory();
|
||||||
|
const deleteStory = useDeleteStory();
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [editingStory, setEditingStory] = useState<{ id: string; name: string; description?: string } | null>(null);
|
||||||
|
const [deletingStoryId, setDeletingStoryId] = useState<string | null>(null);
|
||||||
|
const [newStoryName, setNewStoryName] = useState('');
|
||||||
|
const [newStoryDescription, setNewStoryDescription] = useState('');
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const handleCreateStory = () => {
|
||||||
|
if (!newStoryName.trim()) {
|
||||||
|
toast({
|
||||||
|
title: 'Name required',
|
||||||
|
description: 'Please enter a story name',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createStory.mutate(
|
||||||
|
{
|
||||||
|
name: newStoryName.trim(),
|
||||||
|
description: newStoryDescription.trim() || undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (story) => {
|
||||||
|
setSelectedStoryId(story.id);
|
||||||
|
setCreateDialogOpen(false);
|
||||||
|
setNewStoryName('');
|
||||||
|
setNewStoryDescription('');
|
||||||
|
toast({
|
||||||
|
title: 'Story created',
|
||||||
|
description: `"${story.name}" has been created`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to create story',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditClick = (story: { id: string; name: string; description?: string }) => {
|
||||||
|
setEditingStory(story);
|
||||||
|
setNewStoryName(story.name);
|
||||||
|
setNewStoryDescription(story.description || '');
|
||||||
|
setEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateStory = () => {
|
||||||
|
if (!editingStory || !newStoryName.trim()) {
|
||||||
|
toast({
|
||||||
|
title: 'Name required',
|
||||||
|
description: 'Please enter a story name',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStory.mutate(
|
||||||
|
{
|
||||||
|
storyId: editingStory.id,
|
||||||
|
data: {
|
||||||
|
name: newStoryName.trim(),
|
||||||
|
description: newStoryDescription.trim() || undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setEditDialogOpen(false);
|
||||||
|
setEditingStory(null);
|
||||||
|
setNewStoryName('');
|
||||||
|
setNewStoryDescription('');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to update story',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (storyId: string) => {
|
||||||
|
setDeletingStoryId(storyId);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = () => {
|
||||||
|
if (!deletingStoryId) return;
|
||||||
|
|
||||||
|
deleteStory.mutate(deletingStoryId, {
|
||||||
|
onSuccess: () => {
|
||||||
|
// Clear selection if deleting the currently selected story
|
||||||
|
if (selectedStoryId === deletingStoryId) {
|
||||||
|
setSelectedStoryId(null);
|
||||||
|
}
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setDeletingStoryId(null);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to delete story',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-muted-foreground">Loading stories...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storyList = stories || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4 px-1">
|
||||||
|
<h2 className="text-2xl font-bold">Stories</h2>
|
||||||
|
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
New Story
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Story List */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
|
||||||
|
{storyList.length === 0 ? (
|
||||||
|
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-md text-muted-foreground">
|
||||||
|
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||||
|
<p className="text-sm">No stories yet</p>
|
||||||
|
<p className="text-xs mt-2">Create your first story to get started</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
storyList.map((story) => (
|
||||||
|
<div
|
||||||
|
key={story.id}
|
||||||
|
className={cn(
|
||||||
|
'h-24 p-4 border rounded-md transition-colors group flex items-center',
|
||||||
|
selectedStoryId === story.id && 'bg-muted border-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2 w-full min-w-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 min-w-0 text-left cursor-pointer overflow-hidden"
|
||||||
|
onClick={() => setSelectedStoryId(story.id)}
|
||||||
|
>
|
||||||
|
<h3 className="font-medium truncate">{story.name}</h3>
|
||||||
|
{story.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1 truncate">
|
||||||
|
{story.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
|
||||||
|
<span>{story.item_count} {story.item_count === 1 ? 'item' : 'items'}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{formatDate(story.updated_at)}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => handleEditClick(story)}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDeleteClick(story.id)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Story Dialog */}
|
||||||
|
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Story</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new story to organize your voice generations into conversations.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="story-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="story-name"
|
||||||
|
placeholder="My Story"
|
||||||
|
value={newStoryName}
|
||||||
|
onChange={(e) => setNewStoryName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleCreateStory();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="story-description">Description (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="story-description"
|
||||||
|
placeholder="A conversation between..."
|
||||||
|
value={newStoryDescription}
|
||||||
|
onChange={(e) => setNewStoryDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreateStory} disabled={createStory.isPending}>
|
||||||
|
{createStory.isPending ? 'Creating...' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Edit Story Dialog */}
|
||||||
|
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Story</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Update the story name and description.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-story-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-story-name"
|
||||||
|
placeholder="My Story"
|
||||||
|
value={newStoryName}
|
||||||
|
onChange={(e) => setNewStoryName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleUpdateStory();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-story-description">Description (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="edit-story-description"
|
||||||
|
placeholder="A conversation between..."
|
||||||
|
value={newStoryDescription}
|
||||||
|
onChange={(e) => setNewStoryDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleUpdateStory} disabled={updateStory.isPending}>
|
||||||
|
{updateStory.isPending ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Story Confirmation Dialog */}
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete the story and all its items. This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction asChild>
|
||||||
|
<Button
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
disabled={deleteStory.isPending}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{deleteStory.isPending ? 'Deleting...' : 'Delete'}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,988 @@
|
|||||||
|
import {
|
||||||
|
Copy,
|
||||||
|
GripHorizontal,
|
||||||
|
Minus,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
Scissors,
|
||||||
|
Square,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import WaveSurfer from 'wavesurfer.js';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { StoryItemDetail } from '@/lib/api/types';
|
||||||
|
import {
|
||||||
|
useDuplicateStoryItem,
|
||||||
|
useMoveStoryItem,
|
||||||
|
useRemoveStoryItem,
|
||||||
|
useSplitStoryItem,
|
||||||
|
useTrimStoryItem,
|
||||||
|
} from '@/lib/hooks/useStories';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
|
|
||||||
|
// Clip waveform component with trim support
|
||||||
|
function ClipWaveform({
|
||||||
|
generationId,
|
||||||
|
width,
|
||||||
|
trimStartMs,
|
||||||
|
trimEndMs,
|
||||||
|
duration,
|
||||||
|
}: {
|
||||||
|
generationId: string;
|
||||||
|
width: number;
|
||||||
|
trimStartMs: number;
|
||||||
|
trimEndMs: number;
|
||||||
|
duration: number;
|
||||||
|
}) {
|
||||||
|
const waveformRef = useRef<HTMLDivElement>(null);
|
||||||
|
const wavesurferRef = useRef<WaveSurfer | null>(null);
|
||||||
|
|
||||||
|
// Calculate the full waveform width based on the original duration
|
||||||
|
// The visible portion (width) represents the effective duration after trimming
|
||||||
|
const effectiveDurationMs = duration * 1000 - trimStartMs - trimEndMs;
|
||||||
|
const fullWaveformWidth =
|
||||||
|
effectiveDurationMs > 0 ? (width / effectiveDurationMs) * (duration * 1000) : width;
|
||||||
|
|
||||||
|
// Calculate how much to offset the waveform to hide the trimmed start
|
||||||
|
const offsetX =
|
||||||
|
effectiveDurationMs > 0 ? (trimStartMs / (duration * 1000)) * fullWaveformWidth : 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!waveformRef.current || fullWaveformWidth < 20) return;
|
||||||
|
|
||||||
|
// Get CSS colors
|
||||||
|
const root = document.documentElement;
|
||||||
|
const getCSSVar = (varName: string) => {
|
||||||
|
const value = getComputedStyle(root).getPropertyValue(varName).trim();
|
||||||
|
return value ? `hsl(${value})` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const waveColor = getCSSVar('--accent-foreground');
|
||||||
|
|
||||||
|
const wavesurfer = WaveSurfer.create({
|
||||||
|
container: waveformRef.current,
|
||||||
|
waveColor,
|
||||||
|
progressColor: waveColor,
|
||||||
|
cursorWidth: 0,
|
||||||
|
barWidth: 1,
|
||||||
|
barRadius: 1,
|
||||||
|
barGap: 1,
|
||||||
|
height: 28,
|
||||||
|
normalize: true,
|
||||||
|
interact: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
wavesurferRef.current = wavesurfer;
|
||||||
|
|
||||||
|
const audioUrl = apiClient.getAudioUrl(generationId);
|
||||||
|
wavesurfer.load(audioUrl).catch(() => {
|
||||||
|
// Ignore load errors
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
wavesurfer.destroy();
|
||||||
|
wavesurferRef.current = null;
|
||||||
|
};
|
||||||
|
}, [generationId, fullWaveformWidth]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full opacity-60 overflow-hidden">
|
||||||
|
{/* Inner container that holds the full waveform, offset to show only visible portion */}
|
||||||
|
<div
|
||||||
|
ref={waveformRef}
|
||||||
|
style={{
|
||||||
|
width: `${fullWaveformWidth}px`,
|
||||||
|
transform: `translateX(-${offsetX}px)`,
|
||||||
|
}}
|
||||||
|
className="h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoryTrackEditorProps {
|
||||||
|
storyId: string;
|
||||||
|
items: StoryItemDetail[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRACK_HEIGHT = 48;
|
||||||
|
const TIME_RULER_HEIGHT = 24; // h-6 = 1.5rem = 24px
|
||||||
|
const MIN_PIXELS_PER_SECOND = 10;
|
||||||
|
const MAX_PIXELS_PER_SECOND = 200;
|
||||||
|
const DEFAULT_PIXELS_PER_SECOND = 50;
|
||||||
|
const DEFAULT_TRACKS = [1, 0, -1]; // Default 3 tracks
|
||||||
|
const MIN_EDITOR_HEIGHT = 120;
|
||||||
|
const MAX_EDITOR_HEIGHT = 500;
|
||||||
|
|
||||||
|
export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||||
|
const [pixelsPerSecond, setPixelsPerSecond] = useState(DEFAULT_PIXELS_PER_SECOND);
|
||||||
|
const [draggingItem, setDraggingItem] = useState<string | null>(null);
|
||||||
|
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||||
|
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
const [containerWidth, setContainerWidth] = useState(0);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const tracksRef = useRef<HTMLDivElement>(null);
|
||||||
|
const resizeStartY = useRef(0);
|
||||||
|
const resizeStartHeight = useRef(0);
|
||||||
|
const moveItem = useMoveStoryItem();
|
||||||
|
const trimItem = useTrimStoryItem();
|
||||||
|
const splitItem = useSplitStoryItem();
|
||||||
|
const duplicateItem = useDuplicateStoryItem();
|
||||||
|
const removeItem = useRemoveStoryItem();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Selection state
|
||||||
|
const selectedClipId = useStoryStore((state) => state.selectedClipId);
|
||||||
|
const setSelectedClipId = useStoryStore((state) => state.setSelectedClipId);
|
||||||
|
|
||||||
|
// Trim state
|
||||||
|
const [trimmingItem, setTrimmingItem] = useState<string | null>(null);
|
||||||
|
const [trimSide, setTrimSide] = useState<'start' | 'end' | null>(null);
|
||||||
|
const [trimStartX, setTrimStartX] = useState(0);
|
||||||
|
const [tempTrimValues, setTempTrimValues] = useState<{
|
||||||
|
trim_start_ms: number;
|
||||||
|
trim_end_ms: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Track editor height from store (shared with FloatingGenerateBox)
|
||||||
|
const editorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||||
|
const setEditorHeight = useStoryStore((state) => state.setTrackEditorHeight);
|
||||||
|
|
||||||
|
// Playback state
|
||||||
|
const isPlaying = useStoryStore((state) => state.isPlaying);
|
||||||
|
const currentTimeMs = useStoryStore((state) => state.currentTimeMs);
|
||||||
|
const playbackStoryId = useStoryStore((state) => state.playbackStoryId);
|
||||||
|
const play = useStoryStore((state) => state.play);
|
||||||
|
const pause = useStoryStore((state) => state.pause);
|
||||||
|
const stop = useStoryStore((state) => state.stop);
|
||||||
|
const seek = useStoryStore((state) => state.seek);
|
||||||
|
const setActiveStory = useStoryStore((state) => state.setActiveStory);
|
||||||
|
|
||||||
|
const isActiveStory = playbackStoryId === storyId;
|
||||||
|
const isCurrentlyPlaying = isPlaying && isActiveStory;
|
||||||
|
|
||||||
|
// Auto-activate this story when the editor is shown so playhead is visible
|
||||||
|
useEffect(() => {
|
||||||
|
if (items.length > 0 && !isActiveStory) {
|
||||||
|
const totalDuration = Math.max(
|
||||||
|
...items.map((item) => {
|
||||||
|
const trimStart = item.trim_start_ms || 0;
|
||||||
|
const trimEnd = item.trim_end_ms || 0;
|
||||||
|
const effectiveDuration = item.duration * 1000 - trimStart - trimEnd;
|
||||||
|
return item.start_time_ms + effectiveDuration;
|
||||||
|
}),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
setActiveStory(storyId, items, totalDuration);
|
||||||
|
}
|
||||||
|
}, [storyId, items, isActiveStory, setActiveStory]);
|
||||||
|
|
||||||
|
// Sort items by start time for play
|
||||||
|
const sortedItems = useMemo(() => {
|
||||||
|
return [...items].sort((a, b) => a.start_time_ms - b.start_time_ms);
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const handlePlayPause = () => {
|
||||||
|
if (isCurrentlyPlaying) {
|
||||||
|
pause();
|
||||||
|
} else {
|
||||||
|
play(storyId, sortedItems);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStop = () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate unique tracks from items, always showing at least 3 default tracks
|
||||||
|
const tracks = useMemo(() => {
|
||||||
|
const trackSet = new Set([...DEFAULT_TRACKS, ...items.map((item) => item.track)]);
|
||||||
|
return Array.from(trackSet).sort((a, b) => b - a); // Higher tracks on top
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
// Track container width for full-width minimum
|
||||||
|
useEffect(() => {
|
||||||
|
const container = tracksRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
setContainerWidth(entry.contentRect.width);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(container);
|
||||||
|
// Set initial width
|
||||||
|
setContainerWidth(container.clientWidth);
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Calculate effective duration (accounting for trims)
|
||||||
|
const getEffectiveDuration = (item: StoryItemDetail) => {
|
||||||
|
return item.duration * 1000 - (item.trim_start_ms || 0) - (item.trim_end_ms || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate total duration (using effective durations)
|
||||||
|
const totalDurationMs = useMemo(() => {
|
||||||
|
if (items.length === 0) return 10000; // Default 10 seconds
|
||||||
|
return Math.max(...items.map((item) => item.start_time_ms + getEffectiveDuration(item)), 10000);
|
||||||
|
}, [items, getEffectiveDuration]);
|
||||||
|
|
||||||
|
// Calculate timeline width - at least full container width
|
||||||
|
const contentWidth = (totalDurationMs / 1000) * pixelsPerSecond + 200; // Content width with padding
|
||||||
|
const timelineWidth = Math.max(contentWidth, containerWidth);
|
||||||
|
|
||||||
|
// Generate time markers
|
||||||
|
const timeMarkers = useMemo(() => {
|
||||||
|
const markers: number[] = [];
|
||||||
|
// Determine interval based on zoom level
|
||||||
|
let intervalMs = 5000; // 5 seconds
|
||||||
|
if (pixelsPerSecond > 100) intervalMs = 1000;
|
||||||
|
else if (pixelsPerSecond > 50) intervalMs = 2000;
|
||||||
|
else if (pixelsPerSecond < 20) intervalMs = 10000;
|
||||||
|
|
||||||
|
for (let ms = 0; ms <= totalDurationMs + intervalMs; ms += intervalMs) {
|
||||||
|
markers.push(ms);
|
||||||
|
}
|
||||||
|
return markers;
|
||||||
|
}, [totalDurationMs, pixelsPerSecond]);
|
||||||
|
|
||||||
|
const formatTime = (ms: number): string => {
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const msToPixels = useCallback((ms: number) => (ms / 1000) * pixelsPerSecond, [pixelsPerSecond]);
|
||||||
|
|
||||||
|
const pixelsToMs = useCallback((px: number) => (px / pixelsPerSecond) * 1000, [pixelsPerSecond]);
|
||||||
|
|
||||||
|
const handleZoomIn = () => {
|
||||||
|
setPixelsPerSecond((prev) => Math.min(prev * 1.5, MAX_PIXELS_PER_SECOND));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoomOut = () => {
|
||||||
|
setPixelsPerSecond((prev) => Math.max(prev / 1.5, MIN_PIXELS_PER_SECOND));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resize handlers
|
||||||
|
const handleResizeStart = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsResizing(true);
|
||||||
|
resizeStartY.current = e.clientY;
|
||||||
|
resizeStartHeight.current = editorHeight;
|
||||||
|
},
|
||||||
|
[editorHeight],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleResizeMove = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
if (!isResizing) return;
|
||||||
|
const deltaY = resizeStartY.current - e.clientY;
|
||||||
|
const newHeight = Math.min(
|
||||||
|
MAX_EDITOR_HEIGHT,
|
||||||
|
Math.max(MIN_EDITOR_HEIGHT, resizeStartHeight.current + deltaY),
|
||||||
|
);
|
||||||
|
setEditorHeight(newHeight);
|
||||||
|
},
|
||||||
|
[isResizing, setEditorHeight],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleResizeEnd = useCallback(() => {
|
||||||
|
setIsResizing(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Add global mouse listeners for resizing
|
||||||
|
useEffect(() => {
|
||||||
|
if (isResizing) {
|
||||||
|
window.addEventListener('mousemove', handleResizeMove);
|
||||||
|
window.addEventListener('mouseup', handleResizeEnd);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', handleResizeMove);
|
||||||
|
window.removeEventListener('mouseup', handleResizeEnd);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isResizing, handleResizeMove, handleResizeEnd]);
|
||||||
|
|
||||||
|
const handleTimelineClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!tracksRef.current || draggingItem || trimmingItem) return;
|
||||||
|
const rect = tracksRef.current.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left + tracksRef.current.scrollLeft;
|
||||||
|
const timeMs = Math.max(0, pixelsToMs(x));
|
||||||
|
seek(timeMs);
|
||||||
|
// Deselect clip when clicking on timeline
|
||||||
|
setSelectedClipId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClipClick = (e: React.MouseEvent, item: StoryItemDetail) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (draggingItem || trimmingItem) return;
|
||||||
|
setSelectedClipId(item.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTrimStart = (e: React.MouseEvent, item: StoryItemDetail, side: 'start' | 'end') => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!tracksRef.current) return;
|
||||||
|
setTrimmingItem(item.id);
|
||||||
|
setTrimSide(side);
|
||||||
|
setSelectedClipId(item.id);
|
||||||
|
setTrimStartX(e.clientX);
|
||||||
|
trimStartItemRef.current = {
|
||||||
|
item,
|
||||||
|
initialTrimStart: item.trim_start_ms || 0,
|
||||||
|
initialTrimEnd: item.trim_end_ms || 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const trimStartItemRef = useRef<{
|
||||||
|
item: StoryItemDetail;
|
||||||
|
initialTrimStart: number;
|
||||||
|
initialTrimEnd: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const handleTrimMove = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
if (!trimmingItem || !trimSide || !trimStartItemRef.current) return;
|
||||||
|
|
||||||
|
const deltaX = e.clientX - trimStartX;
|
||||||
|
const deltaMs = pixelsToMs(deltaX); // Signed delta in milliseconds
|
||||||
|
|
||||||
|
const { item, initialTrimStart, initialTrimEnd } = trimStartItemRef.current;
|
||||||
|
const originalDurationMs = item.duration * 1000;
|
||||||
|
|
||||||
|
let newTrimStart = initialTrimStart;
|
||||||
|
let newTrimEnd = initialTrimEnd;
|
||||||
|
|
||||||
|
if (trimSide === 'start') {
|
||||||
|
// Moving right increases trim_start (trims more from start)
|
||||||
|
// Moving left decreases trim_start (restores from start)
|
||||||
|
newTrimStart = Math.round(
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(initialTrimStart + deltaMs, originalDurationMs - initialTrimEnd - 100),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Moving right decreases trim_end (restores from end)
|
||||||
|
// Moving left increases trim_end (trims more from end)
|
||||||
|
newTrimEnd = Math.round(
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(initialTrimEnd - deltaMs, originalDurationMs - initialTrimStart - 100),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that we don't exceed duration
|
||||||
|
if (newTrimStart + newTrimEnd >= originalDurationMs - 100) {
|
||||||
|
return; // Don't allow trimming to less than 100ms
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update temporary trim values for visual feedback
|
||||||
|
setTempTrimValues({
|
||||||
|
trim_start_ms: newTrimStart,
|
||||||
|
trim_end_ms: newTrimEnd,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[trimmingItem, trimSide, trimStartX, pixelsToMs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTrimEnd = useCallback(() => {
|
||||||
|
if (!trimmingItem || !trimSide || !trimStartItemRef.current) {
|
||||||
|
setTrimmingItem(null);
|
||||||
|
setTrimSide(null);
|
||||||
|
setTempTrimValues(null);
|
||||||
|
trimStartItemRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { initialTrimStart, initialTrimEnd } = trimStartItemRef.current;
|
||||||
|
|
||||||
|
// Use temporary trim values if available, otherwise use initial values
|
||||||
|
// Ensure values are integers for the backend
|
||||||
|
const finalTrimStart = Math.round(tempTrimValues?.trim_start_ms ?? initialTrimStart);
|
||||||
|
const finalTrimEnd = Math.round(tempTrimValues?.trim_end_ms ?? initialTrimEnd);
|
||||||
|
|
||||||
|
// Only update if values changed
|
||||||
|
if (finalTrimStart !== initialTrimStart || finalTrimEnd !== initialTrimEnd) {
|
||||||
|
trimItem.mutate(
|
||||||
|
{
|
||||||
|
storyId,
|
||||||
|
itemId: trimmingItem,
|
||||||
|
data: {
|
||||||
|
trim_start_ms: finalTrimStart,
|
||||||
|
trim_end_ms: finalTrimEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to trim clip',
|
||||||
|
description: error instanceof Error ? error.message : String(error),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTrimmingItem(null);
|
||||||
|
setTrimSide(null);
|
||||||
|
setTempTrimValues(null);
|
||||||
|
trimStartItemRef.current = null;
|
||||||
|
}, [trimmingItem, trimSide, tempTrimValues, storyId, trimItem, toast]);
|
||||||
|
|
||||||
|
const handleSplit = useCallback(() => {
|
||||||
|
if (!selectedClipId) return;
|
||||||
|
|
||||||
|
const item = items.find((i) => i.id === selectedClipId);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
const splitTimeMs = currentTimeMs - item.start_time_ms;
|
||||||
|
const effectiveDuration = getEffectiveDuration(item);
|
||||||
|
|
||||||
|
if (splitTimeMs <= 0 || splitTimeMs >= effectiveDuration) {
|
||||||
|
toast({
|
||||||
|
title: 'Invalid split point',
|
||||||
|
description: 'Playhead must be within the selected clip',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
splitItem.mutate(
|
||||||
|
{
|
||||||
|
storyId,
|
||||||
|
itemId: selectedClipId,
|
||||||
|
data: { split_time_ms: splitTimeMs },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelectedClipId(null);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to split clip',
|
||||||
|
description: error instanceof Error ? error.message : String(error),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
selectedClipId,
|
||||||
|
items,
|
||||||
|
currentTimeMs,
|
||||||
|
getEffectiveDuration,
|
||||||
|
storyId,
|
||||||
|
splitItem,
|
||||||
|
toast,
|
||||||
|
setSelectedClipId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleDuplicate = useCallback(() => {
|
||||||
|
if (!selectedClipId) return;
|
||||||
|
|
||||||
|
duplicateItem.mutate(
|
||||||
|
{
|
||||||
|
storyId,
|
||||||
|
itemId: selectedClipId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to duplicate clip',
|
||||||
|
description: error instanceof Error ? error.message : String(error),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [selectedClipId, storyId, duplicateItem, toast]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
if (!selectedClipId) return;
|
||||||
|
|
||||||
|
removeItem.mutate(
|
||||||
|
{
|
||||||
|
storyId,
|
||||||
|
itemId: selectedClipId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelectedClipId(null);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to delete clip',
|
||||||
|
description: error instanceof Error ? error.message : String(error),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [selectedClipId, storyId, removeItem, toast, setSelectedClipId]);
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Only handle shortcuts when editor is focused or no input is focused
|
||||||
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
handlePlayPause();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setSelectedClipId(null);
|
||||||
|
} else if (e.key === 's' || e.key === 'S') {
|
||||||
|
if (selectedClipId) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSplit();
|
||||||
|
}
|
||||||
|
} else if (e.key === 'd' || e.key === 'D') {
|
||||||
|
if (selectedClipId && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleDuplicate();
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
|
if (selectedClipId) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleDelete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [
|
||||||
|
selectedClipId,
|
||||||
|
handleSplit,
|
||||||
|
handleDuplicate,
|
||||||
|
handleDelete,
|
||||||
|
setSelectedClipId,
|
||||||
|
handlePlayPause,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add global mouse listeners for trimming
|
||||||
|
useEffect(() => {
|
||||||
|
if (trimmingItem) {
|
||||||
|
window.addEventListener('mousemove', handleTrimMove);
|
||||||
|
window.addEventListener('mouseup', handleTrimEnd);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', handleTrimMove);
|
||||||
|
window.removeEventListener('mouseup', handleTrimEnd);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [trimmingItem, handleTrimMove, handleTrimEnd]);
|
||||||
|
|
||||||
|
const handleDragStart = (e: React.MouseEvent, item: StoryItemDetail) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!tracksRef.current) return;
|
||||||
|
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
setDragOffset({
|
||||||
|
x: e.clientX - rect.left,
|
||||||
|
y: e.clientY - rect.top,
|
||||||
|
});
|
||||||
|
setDragPosition({
|
||||||
|
x: rect.left - tracksRef.current.getBoundingClientRect().left + tracksRef.current.scrollLeft,
|
||||||
|
// Subtract ruler height since clips are positioned relative to tracks area, not the scrollable container
|
||||||
|
y: rect.top - tracksRef.current.getBoundingClientRect().top - TIME_RULER_HEIGHT,
|
||||||
|
});
|
||||||
|
setDraggingItem(item.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragMove = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (!draggingItem || !tracksRef.current) return;
|
||||||
|
|
||||||
|
const rect = tracksRef.current.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left + tracksRef.current.scrollLeft - dragOffset.x;
|
||||||
|
// Subtract ruler height since clips are positioned relative to tracks area
|
||||||
|
const y = e.clientY - rect.top - dragOffset.y - TIME_RULER_HEIGHT;
|
||||||
|
|
||||||
|
setDragPosition({ x: Math.max(0, x), y });
|
||||||
|
},
|
||||||
|
[draggingItem, dragOffset],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
|
if (!draggingItem || !tracksRef.current) {
|
||||||
|
setDraggingItem(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = items.find((i) => i.id === draggingItem);
|
||||||
|
if (!item) {
|
||||||
|
setDraggingItem(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate new time from x position
|
||||||
|
const newTimeMs = Math.max(0, Math.round(pixelsToMs(dragPosition.x)));
|
||||||
|
|
||||||
|
// Calculate new track from y position
|
||||||
|
const trackIndex = Math.floor(dragPosition.y / TRACK_HEIGHT);
|
||||||
|
const clampedTrackIndex = Math.max(0, Math.min(trackIndex, tracks.length - 1));
|
||||||
|
const newTrack = tracks[clampedTrackIndex] ?? 0;
|
||||||
|
|
||||||
|
// Check if position changed
|
||||||
|
if (newTimeMs !== item.start_time_ms || newTrack !== item.track) {
|
||||||
|
moveItem.mutate(
|
||||||
|
{
|
||||||
|
storyId,
|
||||||
|
itemId: item.id,
|
||||||
|
data: {
|
||||||
|
start_time_ms: newTimeMs,
|
||||||
|
track: newTrack,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to move item',
|
||||||
|
description: error instanceof Error ? error.message : String(error),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraggingItem(null);
|
||||||
|
}, [draggingItem, dragPosition, items, tracks, pixelsToMs, storyId, moveItem, toast]);
|
||||||
|
|
||||||
|
// Get track index for rendering
|
||||||
|
const getTrackIndex = (trackNumber: number) => tracks.indexOf(trackNumber);
|
||||||
|
|
||||||
|
// Calculate clip position and dimensions
|
||||||
|
const getClipStyle = (item: StoryItemDetail) => {
|
||||||
|
const isDragging = draggingItem === item.id;
|
||||||
|
const trackIndex = getTrackIndex(item.track);
|
||||||
|
const effectiveDuration = getEffectiveDuration(item);
|
||||||
|
const width = msToPixels(effectiveDuration);
|
||||||
|
const left = isDragging ? dragPosition.x : msToPixels(item.start_time_ms);
|
||||||
|
const top = isDragging ? dragPosition.y : trackIndex * TRACK_HEIGHT;
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: `${width}px`,
|
||||||
|
left: `${left}px`,
|
||||||
|
top: `${top}px`,
|
||||||
|
height: `${TRACK_HEIGHT - 4}px`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Playhead position
|
||||||
|
const playheadLeft = msToPixels(currentTimeMs);
|
||||||
|
|
||||||
|
// Auto-scroll timeline to follow playhead during playback
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCurrentlyPlaying || !tracksRef.current) return;
|
||||||
|
|
||||||
|
const container = tracksRef.current;
|
||||||
|
const containerWidth = container.clientWidth;
|
||||||
|
const scrollLeft = container.scrollLeft;
|
||||||
|
const halfwayPoint = scrollLeft + containerWidth / 2;
|
||||||
|
|
||||||
|
// If playhead is past the halfway point, scroll to keep it centered
|
||||||
|
if (playheadLeft > halfwayPoint) {
|
||||||
|
const targetScroll = playheadLeft - containerWidth / 2;
|
||||||
|
container.scrollLeft = targetScroll;
|
||||||
|
}
|
||||||
|
}, [isCurrentlyPlaying, playheadLeft]);
|
||||||
|
|
||||||
|
// Calculate tracks area height
|
||||||
|
const tracksAreaHeight = tracks.length * TRACK_HEIGHT;
|
||||||
|
const timelineContainerHeight = editorHeight - 40; // Subtract toolbar height
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 z-50">
|
||||||
|
<div
|
||||||
|
className="border-t bg-background/30 backdrop-blur-2xl overflow-hidden relative"
|
||||||
|
ref={containerRef}
|
||||||
|
>
|
||||||
|
{/* Resize handle at top */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute top-0 left-0 right-0 h-2 cursor-ns-resize flex items-center justify-center hover:bg-muted/50 transition-colors z-20 group"
|
||||||
|
onMouseDown={handleResizeStart}
|
||||||
|
aria-label="Resize track editor"
|
||||||
|
>
|
||||||
|
<GripHorizontal className="h-3 w-3 text-muted-foreground/50 group-hover:text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30 mt-2">
|
||||||
|
{/* Play controls - left side */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handlePlayPause}
|
||||||
|
title="Play/Pause (Space)"
|
||||||
|
>
|
||||||
|
{isCurrentlyPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handleStop}
|
||||||
|
disabled={!isCurrentlyPlaying}
|
||||||
|
>
|
||||||
|
<Square className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums ml-2">
|
||||||
|
{formatTime(currentTimeMs)} / {formatTime(totalDurationMs)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clip editing controls - center */}
|
||||||
|
{selectedClipId && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handleSplit}
|
||||||
|
title="Split at playhead (S)"
|
||||||
|
>
|
||||||
|
<Scissors className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handleDuplicate}
|
||||||
|
title="Duplicate (Cmd/Ctrl+D)"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handleDelete}
|
||||||
|
title="Delete (Delete/Backspace)"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Zoom controls - right side */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">Zoom:</span>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomOut}>
|
||||||
|
<Minus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomIn}>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline container with track labels sidebar */}
|
||||||
|
<div className="flex" style={{ height: `${timelineContainerHeight}px` }}>
|
||||||
|
{/* Track labels sidebar - fixed width */}
|
||||||
|
<div className="w-16 shrink-0 border-r bg-muted/20 overflow-hidden">
|
||||||
|
{/* Spacer for time ruler */}
|
||||||
|
<div className="h-6 border-b bg-muted/30" />
|
||||||
|
{/* Track labels */}
|
||||||
|
<div style={{ height: `${tracksAreaHeight}px` }}>
|
||||||
|
{tracks.map((trackNumber, index) => (
|
||||||
|
<div
|
||||||
|
key={trackNumber}
|
||||||
|
className={cn(
|
||||||
|
'border-b flex items-center justify-center',
|
||||||
|
index % 2 === 0 ? 'bg-background' : 'bg-muted/10',
|
||||||
|
)}
|
||||||
|
style={{ height: `${TRACK_HEIGHT}px` }}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] text-muted-foreground select-none">
|
||||||
|
{trackNumber}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable timeline area */}
|
||||||
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: Container handles drag events for child clips */}
|
||||||
|
<div
|
||||||
|
ref={tracksRef}
|
||||||
|
className="overflow-auto relative flex-1"
|
||||||
|
onMouseMove={draggingItem ? handleDragMove : undefined}
|
||||||
|
onMouseUp={draggingItem ? handleDragEnd : undefined}
|
||||||
|
onMouseLeave={draggingItem ? handleDragEnd : undefined}
|
||||||
|
>
|
||||||
|
{/* Time ruler - clickable to seek */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-6 border-b bg-muted/20 sticky top-0 z-10 cursor-pointer text-left"
|
||||||
|
style={{ width: `${timelineWidth}px` }}
|
||||||
|
onClick={handleTimelineClick}
|
||||||
|
aria-label="Seek timeline"
|
||||||
|
>
|
||||||
|
{timeMarkers.map((ms) => (
|
||||||
|
<div
|
||||||
|
key={ms}
|
||||||
|
className="absolute top-0 h-full flex flex-col justify-end pointer-events-none"
|
||||||
|
style={{ left: `${msToPixels(ms)}px` }}
|
||||||
|
>
|
||||||
|
<div className="h-2 w-px bg-border" />
|
||||||
|
<span className="text-[10px] text-muted-foreground ml-1 select-none">
|
||||||
|
{formatTime(ms)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Tracks area */}
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
style={{ width: `${timelineWidth}px`, height: `${tracksAreaHeight}px` }}
|
||||||
|
>
|
||||||
|
{/* Track backgrounds - pointer-events-none to allow clicks to pass through */}
|
||||||
|
{tracks.map((trackNumber, index) => (
|
||||||
|
<div
|
||||||
|
key={trackNumber}
|
||||||
|
className={cn(
|
||||||
|
'absolute left-0 right-0 border-b pointer-events-none',
|
||||||
|
index % 2 === 0 ? 'bg-background' : 'bg-muted/10',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
top: `${index * TRACK_HEIGHT}px`,
|
||||||
|
height: `${TRACK_HEIGHT}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Click area for seeking - z-index lower than clips */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-0 z-0 cursor-pointer"
|
||||||
|
onClick={handleTimelineClick}
|
||||||
|
aria-label="Seek timeline"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Audio clips */}
|
||||||
|
{items.map((item) => {
|
||||||
|
const isDragging = draggingItem === item.id;
|
||||||
|
const isSelected = selectedClipId === item.id;
|
||||||
|
const isTrimming = trimmingItem === item.id;
|
||||||
|
|
||||||
|
// Use temporary trim values during trimming for visual feedback
|
||||||
|
const displayTrimStart =
|
||||||
|
isTrimming && tempTrimValues
|
||||||
|
? tempTrimValues.trim_start_ms
|
||||||
|
: item.trim_start_ms || 0;
|
||||||
|
const displayTrimEnd =
|
||||||
|
isTrimming && tempTrimValues ? tempTrimValues.trim_end_ms : item.trim_end_ms || 0;
|
||||||
|
const effectiveDuration = item.duration * 1000 - displayTrimStart - displayTrimEnd;
|
||||||
|
|
||||||
|
const style = getClipStyle({
|
||||||
|
...item,
|
||||||
|
trim_start_ms: displayTrimStart,
|
||||||
|
trim_end_ms: displayTrimEnd,
|
||||||
|
});
|
||||||
|
const clipWidth = msToPixels(effectiveDuration);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={cn(
|
||||||
|
'absolute rounded select-none overflow-visible z-10',
|
||||||
|
isSelected && 'ring-2 ring-primary ring-offset-1',
|
||||||
|
isTrimming && 'ring-2 ring-accent',
|
||||||
|
)}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'w-full h-full rounded cursor-move overflow-hidden',
|
||||||
|
'bg-accent/80 hover:bg-accent border border-accent-foreground/20',
|
||||||
|
'flex flex-col justify-center',
|
||||||
|
isDragging && 'opacity-80 shadow-lg z-20',
|
||||||
|
!isDragging && 'transition-all duration-100',
|
||||||
|
)}
|
||||||
|
onClick={(e) => handleClipClick(e, item)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
// Only start drag if not clicking on trim handles
|
||||||
|
if (!(e.target as HTMLElement).closest('.trim-handle')) {
|
||||||
|
handleDragStart(e, item);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Clip label */}
|
||||||
|
<div className="absolute top-0 left-1 right-1 z-10">
|
||||||
|
<p className="text-[9px] font-medium text-accent-foreground truncate">
|
||||||
|
{item.profile_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* Waveform */}
|
||||||
|
<div className="absolute inset-0 top-3">
|
||||||
|
<ClipWaveform
|
||||||
|
generationId={item.generation_id}
|
||||||
|
width={clipWidth}
|
||||||
|
trimStartMs={displayTrimStart}
|
||||||
|
trimEndMs={displayTrimEnd}
|
||||||
|
duration={item.duration}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Trim handles */}
|
||||||
|
{isSelected && (
|
||||||
|
<>
|
||||||
|
{/* Left trim handle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="trim-handle absolute left-0 top-0 bottom-0 w-2 cursor-ew-resize hover:bg-primary/30 bg-primary/20 z-30 rounded-l"
|
||||||
|
onMouseDown={(e) => handleTrimStart(e, item, 'start')}
|
||||||
|
aria-label="Trim start"
|
||||||
|
/>
|
||||||
|
{/* Right trim handle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="trim-handle absolute right-0 top-0 bottom-0 w-2 cursor-ew-resize hover:bg-primary/30 bg-primary/20 z-30 rounded-r"
|
||||||
|
onMouseDown={(e) => handleTrimStart(e, item, 'end')}
|
||||||
|
aria-label="Trim end"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Playhead - always visible */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 w-1 bg-accent z-30 pointer-events-none rounded-full"
|
||||||
|
style={{ left: `${playheadLeft}px` }}
|
||||||
|
>
|
||||||
|
<div className="absolute -top-1 left-1/2 -translate-x-1/2 w-3 h-3 bg-accent rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Mic, Monitor, Upload } from 'lucide-react';
|
import { Mic, Monitor, Upload, X } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
@@ -42,45 +42,13 @@ import {
|
|||||||
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 { isTauri } from '@/lib/tauri';
|
||||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
import { formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore, type ProfileFormDraft } 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';
|
||||||
import { SampleList } from './SampleList';
|
import { SampleList } from './SampleList';
|
||||||
|
|
||||||
// Helper function to get audio duration from File
|
|
||||||
async function getAudioDuration(file: File & { recordedDuration?: number }): Promise<number> {
|
|
||||||
// If the file has a recordedDuration property (from our recording hooks),
|
|
||||||
// use that instead of trying to read metadata. This fixes issues on Windows
|
|
||||||
// where WebM files from MediaRecorder don't have proper duration metadata.
|
|
||||||
if (file.recordedDuration !== undefined && Number.isFinite(file.recordedDuration)) {
|
|
||||||
return file.recordedDuration;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const audio = new Audio();
|
|
||||||
const url = URL.createObjectURL(file);
|
|
||||||
|
|
||||||
audio.addEventListener('loadedmetadata', () => {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
// Check if duration is valid (not Infinity or NaN)
|
|
||||||
if (Number.isFinite(audio.duration) && audio.duration > 0) {
|
|
||||||
resolve(audio.duration);
|
|
||||||
} else {
|
|
||||||
reject(new Error('Audio file has invalid duration metadata'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
audio.addEventListener('error', () => {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
reject(new Error('Failed to load audio file'));
|
|
||||||
});
|
|
||||||
|
|
||||||
audio.src = url;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_AUDIO_DURATION_SECONDS = 30;
|
const MAX_AUDIO_DURATION_SECONDS = 30;
|
||||||
|
|
||||||
const baseProfileSchema = z.object({
|
const baseProfileSchema = z.object({
|
||||||
@@ -107,11 +75,35 @@ const profileSchema = baseProfileSchema.refine(
|
|||||||
|
|
||||||
type ProfileFormValues = z.infer<typeof profileSchema>;
|
type ProfileFormValues = z.infer<typeof profileSchema>;
|
||||||
|
|
||||||
|
// Helper to convert File to base64
|
||||||
|
async function fileToBase64(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to convert base64 to File
|
||||||
|
function base64ToFile(base64: string, fileName: string, fileType: string): File {
|
||||||
|
const arr = base64.split(',');
|
||||||
|
const bstr = atob(arr[1]);
|
||||||
|
let n = bstr.length;
|
||||||
|
const u8arr = new Uint8Array(n);
|
||||||
|
while (n--) {
|
||||||
|
u8arr[n] = bstr.charCodeAt(n);
|
||||||
|
}
|
||||||
|
return new File([u8arr], fileName, { type: fileType });
|
||||||
|
}
|
||||||
|
|
||||||
export function ProfileForm() {
|
export function ProfileForm() {
|
||||||
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);
|
||||||
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
||||||
|
const profileFormDraft = useUIStore((state) => state.profileFormDraft);
|
||||||
|
const setProfileFormDraft = useUIStore((state) => state.setProfileFormDraft);
|
||||||
const { data: editingProfile } = useProfile(editingProfileId || '');
|
const { data: editingProfile } = useProfile(editingProfileId || '');
|
||||||
const createProfile = useCreateProfile();
|
const createProfile = useCreateProfile();
|
||||||
const updateProfile = useUpdateProfile();
|
const updateProfile = useUpdateProfile();
|
||||||
@@ -187,7 +179,7 @@ export function ProfileForm() {
|
|||||||
stopRecording,
|
stopRecording,
|
||||||
cancelRecording,
|
cancelRecording,
|
||||||
} = useAudioRecording({
|
} = useAudioRecording({
|
||||||
maxDurationSeconds: 30,
|
maxDurationSeconds: 29,
|
||||||
onRecordingComplete: (blob, recordedDuration) => {
|
onRecordingComplete: (blob, recordedDuration) => {
|
||||||
const file = new File([blob], `recording-${Date.now()}.webm`, {
|
const file = new File([blob], `recording-${Date.now()}.webm`, {
|
||||||
type: blob.type || 'audio/webm',
|
type: blob.type || 'audio/webm',
|
||||||
@@ -213,7 +205,7 @@ export function ProfileForm() {
|
|||||||
stopRecording: stopSystemRecording,
|
stopRecording: stopSystemRecording,
|
||||||
cancelRecording: cancelSystemRecording,
|
cancelRecording: cancelSystemRecording,
|
||||||
} = useSystemAudioCapture({
|
} = useSystemAudioCapture({
|
||||||
maxDurationSeconds: 30,
|
maxDurationSeconds: 29,
|
||||||
onRecordingComplete: (blob, recordedDuration) => {
|
onRecordingComplete: (blob, recordedDuration) => {
|
||||||
const file = new File([blob], `system-audio-${Date.now()}.wav`, {
|
const file = new File([blob], `system-audio-${Date.now()}.wav`, {
|
||||||
type: blob.type || 'audio/wav',
|
type: blob.type || 'audio/wav',
|
||||||
@@ -252,6 +244,7 @@ export function ProfileForm() {
|
|||||||
}
|
}
|
||||||
}, [systemRecordingError, toast]);
|
}, [systemRecordingError, toast]);
|
||||||
|
|
||||||
|
// Restore form state from draft or editing profile
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingProfile) {
|
if (editingProfile) {
|
||||||
form.reset({
|
form.reset({
|
||||||
@@ -261,7 +254,27 @@ export function ProfileForm() {
|
|||||||
sampleFile: undefined,
|
sampleFile: undefined,
|
||||||
referenceText: undefined,
|
referenceText: undefined,
|
||||||
});
|
});
|
||||||
} else {
|
} else if (profileFormDraft && open) {
|
||||||
|
// Restore from draft when opening in create mode
|
||||||
|
form.reset({
|
||||||
|
name: profileFormDraft.name,
|
||||||
|
description: profileFormDraft.description,
|
||||||
|
language: profileFormDraft.language as LanguageCode,
|
||||||
|
referenceText: profileFormDraft.referenceText,
|
||||||
|
sampleFile: undefined,
|
||||||
|
});
|
||||||
|
setSampleMode(profileFormDraft.sampleMode);
|
||||||
|
// Restore the file if we have it saved
|
||||||
|
if (profileFormDraft.sampleFileData && profileFormDraft.sampleFileName && profileFormDraft.sampleFileType) {
|
||||||
|
const file = base64ToFile(
|
||||||
|
profileFormDraft.sampleFileData,
|
||||||
|
profileFormDraft.sampleFileName,
|
||||||
|
profileFormDraft.sampleFileType
|
||||||
|
);
|
||||||
|
form.setValue('sampleFile', file);
|
||||||
|
}
|
||||||
|
} else if (!open) {
|
||||||
|
// Only reset to defaults when modal is closed and no draft
|
||||||
form.reset({
|
form.reset({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -271,7 +284,7 @@ export function ProfileForm() {
|
|||||||
});
|
});
|
||||||
setSampleMode('upload');
|
setSampleMode('upload');
|
||||||
}
|
}
|
||||||
}, [editingProfile, form]);
|
}, [editingProfile, profileFormDraft, open, form]);
|
||||||
|
|
||||||
async function handleTranscribe() {
|
async function handleTranscribe() {
|
||||||
const file = form.getValues('sampleFile');
|
const file = form.getValues('sampleFile');
|
||||||
@@ -415,6 +428,8 @@ export function ProfileForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear draft and reset form on success
|
||||||
|
setProfileFormDraft(null);
|
||||||
form.reset();
|
form.reset();
|
||||||
setEditingProfileId(null);
|
setEditingProfileId(null);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -427,12 +442,40 @@ export function ProfileForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleOpenChange(open: boolean) {
|
async function handleOpenChange(newOpen: boolean) {
|
||||||
setOpen(open);
|
if (!newOpen && isCreating) {
|
||||||
if (!open) {
|
// Save draft when closing the create modal
|
||||||
|
const values = form.getValues();
|
||||||
|
const hasContent = values.name || values.description || values.referenceText || values.sampleFile;
|
||||||
|
|
||||||
|
if (hasContent) {
|
||||||
|
const draft: ProfileFormDraft = {
|
||||||
|
name: values.name || '',
|
||||||
|
description: values.description || '',
|
||||||
|
language: values.language || 'en',
|
||||||
|
referenceText: values.referenceText || '',
|
||||||
|
sampleMode,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save file as base64 if present
|
||||||
|
if (values.sampleFile) {
|
||||||
|
try {
|
||||||
|
draft.sampleFileName = values.sampleFile.name;
|
||||||
|
draft.sampleFileType = values.sampleFile.type;
|
||||||
|
draft.sampleFileData = await fileToBase64(values.sampleFile);
|
||||||
|
} catch {
|
||||||
|
// If file conversion fails, just don't save the file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setProfileFormDraft(draft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(newOpen);
|
||||||
|
if (!newOpen) {
|
||||||
setEditingProfileId(null);
|
setEditingProfileId(null);
|
||||||
form.reset();
|
// Don't reset form here - let the effect handle it based on draft state
|
||||||
setSampleMode('upload');
|
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
cancelRecording();
|
cancelRecording();
|
||||||
}
|
}
|
||||||
@@ -453,6 +496,31 @@ export function ProfileForm() {
|
|||||||
? 'Update your voice profile details and manage samples.'
|
? 'Update your voice profile details and manage samples.'
|
||||||
: 'Create a new voice profile with an audio sample to clone the voice.'}
|
: 'Create a new voice profile with an audio sample to clone the voice.'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
{isCreating && profileFormDraft && (
|
||||||
|
<div className="flex items-center gap-2 pt-2">
|
||||||
|
<span className="text-xs text-muted-foreground">Draft restored</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs text-muted-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
setProfileFormDraft(null);
|
||||||
|
form.reset({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
language: 'en',
|
||||||
|
sampleFile: undefined,
|
||||||
|
referenceText: '',
|
||||||
|
});
|
||||||
|
setSampleMode('upload');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 mr-1" />
|
||||||
|
Discard
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
|
|||||||
@@ -1,11 +1,132 @@
|
|||||||
import { Plus, Trash2, Play } from 'lucide-react';
|
import { Plus, Trash2, Play, Pencil, Check, X, Volume2, Pause } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useDeleteSample, useProfileSamples } from '@/lib/hooks/useProfiles';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
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 { formatAudioDuration } from '@/lib/utils/audio';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { SampleUpload } from './SampleUpload';
|
import { SampleUpload } from './SampleUpload';
|
||||||
|
|
||||||
|
interface MiniSamplePlayerProps {
|
||||||
|
audioUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = new Audio(audioUrl);
|
||||||
|
audioRef.current = audio;
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
setDuration(audio.duration);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
setCurrentTime(audio.currentTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnded = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlay = () => setIsPlaying(true);
|
||||||
|
const handlePause = () => setIsPlaying(false);
|
||||||
|
|
||||||
|
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
|
audio.addEventListener('timeupdate', handleTimeUpdate);
|
||||||
|
audio.addEventListener('ended', handleEnded);
|
||||||
|
audio.addEventListener('play', handlePlay);
|
||||||
|
audio.addEventListener('pause', handlePause);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audio.pause();
|
||||||
|
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
|
audio.removeEventListener('timeupdate', handleTimeUpdate);
|
||||||
|
audio.removeEventListener('ended', handleEnded);
|
||||||
|
audio.removeEventListener('play', handlePlay);
|
||||||
|
audio.removeEventListener('pause', handlePause);
|
||||||
|
audio.src = '';
|
||||||
|
};
|
||||||
|
}, [audioUrl]);
|
||||||
|
|
||||||
|
const handlePlayPause = () => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
if (isPlaying) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
} else {
|
||||||
|
audioRef.current.play();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeek = (value: number[]) => {
|
||||||
|
if (!audioRef.current || duration === 0) return;
|
||||||
|
const progress = value[0] / 100;
|
||||||
|
audioRef.current.currentTime = progress * duration;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStop = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current.currentTime = 0;
|
||||||
|
}
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t bg-muted/30 px-3 py-2 mt-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
onClick={handlePlayPause}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5 ml-0.5" />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||||
|
<Slider
|
||||||
|
value={duration > 0 ? [(currentTime / duration) * 100] : [0]}
|
||||||
|
onValueChange={handleSeek}
|
||||||
|
max={100}
|
||||||
|
step={0.1}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 min-w-[70px]">
|
||||||
|
<span className="font-mono">{formatAudioDuration(currentTime)}</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="font-mono">{formatAudioDuration(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
onClick={handleStop}
|
||||||
|
title="Stop"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface SampleListProps {
|
interface SampleListProps {
|
||||||
profileId: string;
|
profileId: string;
|
||||||
}
|
}
|
||||||
@@ -13,10 +134,11 @@ interface SampleListProps {
|
|||||||
export function SampleList({ profileId }: SampleListProps) {
|
export function SampleList({ profileId }: SampleListProps) {
|
||||||
const { data: samples, isLoading } = useProfileSamples(profileId);
|
const { data: samples, isLoading } = useProfileSamples(profileId);
|
||||||
const deleteSample = useDeleteSample();
|
const deleteSample = useDeleteSample();
|
||||||
|
const updateSample = useUpdateSample();
|
||||||
|
const { toast } = useToast();
|
||||||
const [uploadOpen, setUploadOpen] = useState(false);
|
const [uploadOpen, setUploadOpen] = useState(false);
|
||||||
const setAudio = usePlayerStore((state) => state.setAudio);
|
const [editingSampleId, setEditingSampleId] = useState<string | null>(null);
|
||||||
const currentAudioId = usePlayerStore((state) => state.audioId);
|
const [editedText, setEditedText] = useState<string>('');
|
||||||
const isPlaying = usePlayerStore((state) => state.isPlaying);
|
|
||||||
|
|
||||||
const handleDelete = (sampleId: string) => {
|
const handleDelete = (sampleId: string) => {
|
||||||
if (confirm('Are you sure you want to delete this sample?')) {
|
if (confirm('Are you sure you want to delete this sample?')) {
|
||||||
@@ -24,9 +146,41 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePlay = (referenceText: string, sampleId: string) => {
|
const handleStartEdit = (sampleId: string, currentText: string) => {
|
||||||
const audioUrl = apiClient.getSampleUrl(sampleId);
|
setEditingSampleId(sampleId);
|
||||||
setAudio(audioUrl, sampleId, referenceText.substring(0, 50));
|
setEditedText(currentText);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setEditingSampleId(null);
|
||||||
|
setEditedText('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = async (sampleId: string) => {
|
||||||
|
if (!editedText.trim()) {
|
||||||
|
toast({
|
||||||
|
title: 'Invalid text',
|
||||||
|
description: 'Reference text cannot be empty.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateSample.mutateAsync({ sampleId, referenceText: editedText.trim() });
|
||||||
|
toast({
|
||||||
|
title: 'Sample updated',
|
||||||
|
description: 'Reference text has been updated successfully.',
|
||||||
|
});
|
||||||
|
setEditingSampleId(null);
|
||||||
|
setEditedText('');
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Update failed',
|
||||||
|
description: error instanceof Error ? error.message : 'Failed to update sample',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -44,43 +198,109 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{samples && samples.length === 0 ? (
|
{samples && samples.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground py-4">
|
<div className="flex flex-col items-center justify-center py-8 text-center border border-dashed rounded-lg">
|
||||||
No samples yet. Add your first audio sample.
|
<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-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">
|
||||||
{samples?.map((sample) => (
|
{samples?.map((sample, index) => {
|
||||||
<div
|
const isEditing = editingSampleId === sample.id;
|
||||||
key={sample.id}
|
|
||||||
className="flex items-center justify-between p-3 border rounded-lg"
|
return (
|
||||||
>
|
<div
|
||||||
<div className="flex-1">
|
key={sample.id}
|
||||||
<p className="text-sm font-medium">{sample.reference_text}</p>
|
className={cn(
|
||||||
<p className="text-xs text-muted-foreground mt-1">{sample.audio_path}</p>
|
'group relative rounded-lg border bg-card transition-all duration-200',
|
||||||
|
isEditing ? 'ring-2 ring-primary/20' : 'hover:border-primary/30'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isEditing ? (
|
||||||
|
/* Edit Mode */
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
<span>Editing transcription</span>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={editedText}
|
||||||
|
onChange={(e) => setEditedText(e.target.value)}
|
||||||
|
className="min-h-[100px] text-sm resize-none"
|
||||||
|
placeholder="Enter reference text..."
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
disabled={updateSample.isPending}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 mr-1" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleSaveEdit(sample.id)}
|
||||||
|
disabled={updateSample.isPending}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4 mr-1" />
|
||||||
|
{updateSample.isPending ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* View Mode */}
|
||||||
|
<div className="flex items-center gap-3 p-3 h-[72px]">
|
||||||
|
{/* Text Content */}
|
||||||
|
<div className="flex-1 min-w-0 py-0.5">
|
||||||
|
<p className="text-sm font-medium line-clamp-2 leading-snug">
|
||||||
|
{sample.reference_text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="shrink-0 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
title="Edit transcription"
|
||||||
|
onClick={() => handleStartEdit(sample.id, sample.reference_text)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||||
|
title="Delete sample"
|
||||||
|
onClick={() => handleDelete(sample.id)}
|
||||||
|
disabled={deleteSample.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sample Number Badge */}
|
||||||
|
<div className="absolute top-1 right-2 text-[10px] text-muted-foreground/50 font-medium">
|
||||||
|
#{index + 1}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mini Player - Always visible */}
|
||||||
|
<MiniSamplePlayer audioUrl={apiClient.getSampleUrl(sample.id)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
);
|
||||||
<Button
|
})}
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handlePlay(sample.reference_text, sample.id)}
|
|
||||||
className={currentAudioId === sample.id && isPlaying ? 'text-primary' : ''}
|
|
||||||
>
|
|
||||||
<Play className="h-4 w-4 mr-1" />
|
|
||||||
Play
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDelete(sample.id)}
|
|
||||||
disabled={deleteSample.isPending}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Mic, Monitor, Upload } from 'lucide-react';
|
import { Mic, Monitor, Upload } from 'lucide-react';
|
||||||
import { useState, useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -73,7 +73,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
stopRecording,
|
stopRecording,
|
||||||
cancelRecording,
|
cancelRecording,
|
||||||
} = useAudioRecording({
|
} = useAudioRecording({
|
||||||
maxDurationSeconds: 30,
|
maxDurationSeconds: 29,
|
||||||
onRecordingComplete: (blob, recordedDuration) => {
|
onRecordingComplete: (blob, recordedDuration) => {
|
||||||
// Convert blob to File object
|
// Convert blob to File object
|
||||||
const file = new File([blob], `recording-${Date.now()}.webm`, {
|
const file = new File([blob], `recording-${Date.now()}.webm`, {
|
||||||
@@ -100,7 +100,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
stopRecording: stopSystemRecording,
|
stopRecording: stopSystemRecording,
|
||||||
cancelRecording: cancelSystemRecording,
|
cancelRecording: cancelSystemRecording,
|
||||||
} = useSystemAudioCapture({
|
} = useSystemAudioCapture({
|
||||||
maxDurationSeconds: 30,
|
maxDurationSeconds: 29,
|
||||||
onRecordingComplete: (blob, recordedDuration) => {
|
onRecordingComplete: (blob, recordedDuration) => {
|
||||||
// Convert blob to File object
|
// Convert blob to File object
|
||||||
const file = new File([blob], `system-audio-${Date.now()}.wav`, {
|
const file = new File([blob], `system-audio-${Date.now()}.wav`, {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root;
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
));
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent };
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
interface Window {
|
||||||
|
__voiceboxServerStartedByApp?: boolean;
|
||||||
|
}
|
||||||
+119
-1
@@ -1,4 +1,5 @@
|
|||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
import type { LanguageCode } from '@/lib/constants/languages';
|
||||||
import type {
|
import type {
|
||||||
VoiceProfileCreate,
|
VoiceProfileCreate,
|
||||||
VoiceProfileResponse,
|
VoiceProfileResponse,
|
||||||
@@ -13,6 +14,16 @@ import type {
|
|||||||
ModelStatusListResponse,
|
ModelStatusListResponse,
|
||||||
ModelDownloadRequest,
|
ModelDownloadRequest,
|
||||||
ActiveTasksResponse,
|
ActiveTasksResponse,
|
||||||
|
StoryCreate,
|
||||||
|
StoryResponse,
|
||||||
|
StoryDetailResponse,
|
||||||
|
StoryItemCreate,
|
||||||
|
StoryItemDetail,
|
||||||
|
StoryItemBatchUpdate,
|
||||||
|
StoryItemReorder,
|
||||||
|
StoryItemMove,
|
||||||
|
StoryItemTrim,
|
||||||
|
StoryItemSplit,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
@@ -110,6 +121,16 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateProfileSample(
|
||||||
|
sampleId: string,
|
||||||
|
referenceText: string,
|
||||||
|
): Promise<ProfileSampleResponse> {
|
||||||
|
return this.request<ProfileSampleResponse>(`/profiles/samples/${sampleId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ reference_text: referenceText }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async exportProfile(profileId: string): Promise<Blob> {
|
async exportProfile(profileId: string): Promise<Blob> {
|
||||||
const url = `${this.getBaseUrl()}/profiles/${profileId}/export`;
|
const url = `${this.getBaseUrl()}/profiles/${profileId}/export`;
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
@@ -234,7 +255,7 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Transcription
|
// Transcription
|
||||||
async transcribeAudio(file: File, language?: 'en' | 'zh'): Promise<TranscriptionResponse> {
|
async transcribeAudio(file: File, language?: LanguageCode): Promise<TranscriptionResponse> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
if (language) {
|
if (language) {
|
||||||
@@ -361,6 +382,103 @@ class ApiClient {
|
|||||||
body: JSON.stringify({ channel_ids: channelIds }),
|
body: JSON.stringify({ channel_ids: channelIds }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stories
|
||||||
|
async listStories(): Promise<StoryResponse[]> {
|
||||||
|
return this.request<StoryResponse[]>('/stories');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createStory(data: StoryCreate): Promise<StoryResponse> {
|
||||||
|
return this.request<StoryResponse>('/stories', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStory(storyId: string): Promise<StoryDetailResponse> {
|
||||||
|
return this.request<StoryDetailResponse>(`/stories/${storyId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStory(storyId: string, data: StoryCreate): Promise<StoryResponse> {
|
||||||
|
return this.request<StoryResponse>(`/stories/${storyId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteStory(storyId: string): Promise<void> {
|
||||||
|
await this.request<void>(`/stories/${storyId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addStoryItem(storyId: string, data: StoryItemCreate): Promise<StoryItemDetail> {
|
||||||
|
return this.request<StoryItemDetail>(`/stories/${storyId}/items`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeStoryItem(storyId: string, itemId: string): Promise<void> {
|
||||||
|
await this.request<void>(`/stories/${storyId}/items/${itemId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStoryItemTimes(storyId: string, data: StoryItemBatchUpdate): Promise<void> {
|
||||||
|
await this.request<void>(`/stories/${storyId}/items/times`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async reorderStoryItems(storyId: string, data: StoryItemReorder): Promise<StoryItemDetail[]> {
|
||||||
|
return this.request<StoryItemDetail[]>(`/stories/${storyId}/items/reorder`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveStoryItem(storyId: string, itemId: string, data: StoryItemMove): Promise<StoryItemDetail> {
|
||||||
|
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/move`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async trimStoryItem(storyId: string, itemId: string, data: StoryItemTrim): Promise<StoryItemDetail> {
|
||||||
|
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/trim`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async splitStoryItem(storyId: string, itemId: string, data: StoryItemSplit): Promise<StoryItemDetail[]> {
|
||||||
|
return this.request<StoryItemDetail[]>(`/stories/${storyId}/items/${itemId}/split`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async duplicateStoryItem(storyId: string, itemId: string): Promise<StoryItemDetail> {
|
||||||
|
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/duplicate`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportStoryAudio(storyId: string): Promise<Blob> {
|
||||||
|
const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({
|
||||||
|
detail: response.statusText,
|
||||||
|
}));
|
||||||
|
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiClient = new ApiClient();
|
export const apiClient = new ApiClient();
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// API Types matching backend Pydantic models
|
// API Types matching backend Pydantic models
|
||||||
|
import type { LanguageCode } from '@/lib/constants/languages';
|
||||||
|
|
||||||
export interface VoiceProfileCreate {
|
export interface VoiceProfileCreate {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
language: 'en' | 'zh';
|
language: LanguageCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VoiceProfileResponse {
|
export interface VoiceProfileResponse {
|
||||||
@@ -29,7 +30,7 @@ export interface ProfileSampleResponse {
|
|||||||
export interface GenerationRequest {
|
export interface GenerationRequest {
|
||||||
profile_id: string;
|
profile_id: string;
|
||||||
text: string;
|
text: string;
|
||||||
language: 'en' | 'zh';
|
language: LanguageCode;
|
||||||
seed?: number;
|
seed?: number;
|
||||||
model_size?: '1.7B' | '0.6B';
|
model_size?: '1.7B' | '0.6B';
|
||||||
}
|
}
|
||||||
@@ -62,7 +63,7 @@ export interface HistoryListResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TranscriptionRequest {
|
export interface TranscriptionRequest {
|
||||||
language?: 'en' | 'zh';
|
language?: LanguageCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TranscriptionResponse {
|
export interface TranscriptionResponse {
|
||||||
@@ -123,3 +124,79 @@ export interface ActiveTasksResponse {
|
|||||||
downloads: ActiveDownloadTask[];
|
downloads: ActiveDownloadTask[];
|
||||||
generations: ActiveGenerationTask[];
|
generations: ActiveGenerationTask[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StoryCreate {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
item_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryItemDetail {
|
||||||
|
id: string;
|
||||||
|
story_id: string;
|
||||||
|
generation_id: string;
|
||||||
|
start_time_ms: number;
|
||||||
|
track: number;
|
||||||
|
trim_start_ms: number;
|
||||||
|
trim_end_ms: number;
|
||||||
|
created_at: string;
|
||||||
|
profile_id: string;
|
||||||
|
profile_name: string;
|
||||||
|
text: string;
|
||||||
|
language: string;
|
||||||
|
audio_path: string;
|
||||||
|
duration: number;
|
||||||
|
seed?: number;
|
||||||
|
instruct?: string;
|
||||||
|
generation_created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryDetailResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
items: StoryItemDetail[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryItemCreate {
|
||||||
|
generation_id: string;
|
||||||
|
start_time_ms?: number;
|
||||||
|
track?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryItemUpdateTime {
|
||||||
|
generation_id: string;
|
||||||
|
start_time_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryItemBatchUpdate {
|
||||||
|
updates: StoryItemUpdateTime[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryItemReorder {
|
||||||
|
generation_ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryItemMove {
|
||||||
|
start_time_ms: number;
|
||||||
|
track: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryItemTrim {
|
||||||
|
trim_start_ms: number;
|
||||||
|
trim_end_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryItemSplit {
|
||||||
|
split_time_ms: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface UseAudioRecordingOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useAudioRecording({
|
export function useAudioRecording({
|
||||||
maxDurationSeconds = 30,
|
maxDurationSeconds = 29,
|
||||||
onRecordingComplete,
|
onRecordingComplete,
|
||||||
}: UseAudioRecordingOptions = {}) {
|
}: UseAudioRecordingOptions = {}) {
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import * as z from 'zod';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import { LANGUAGE_CODES, type LanguageCode } from '@/lib/constants/languages';
|
||||||
|
import { useGeneration } from '@/lib/hooks/useGeneration';
|
||||||
|
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||||
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
|
const generationSchema = z.object({
|
||||||
|
text: z.string().min(1, 'Text is required').max(5000),
|
||||||
|
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
||||||
|
seed: z.number().int().optional(),
|
||||||
|
modelSize: z.enum(['1.7B', '0.6B']).optional(),
|
||||||
|
instruct: z.string().max(500).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GenerationFormValues = z.infer<typeof generationSchema>;
|
||||||
|
|
||||||
|
interface UseGenerationFormOptions {
|
||||||
|
onSuccess?: (generationId: string) => void;
|
||||||
|
defaultValues?: Partial<GenerationFormValues>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const generation = useGeneration();
|
||||||
|
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
|
||||||
|
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
|
||||||
|
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
|
||||||
|
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useModelDownloadToast({
|
||||||
|
modelName: downloadingModelName || '',
|
||||||
|
displayName: downloadingDisplayName || '',
|
||||||
|
enabled: !!downloadingModelName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<GenerationFormValues>({
|
||||||
|
resolver: zodResolver(generationSchema),
|
||||||
|
defaultValues: {
|
||||||
|
text: '',
|
||||||
|
language: 'en',
|
||||||
|
seed: undefined,
|
||||||
|
modelSize: '1.7B',
|
||||||
|
instruct: '',
|
||||||
|
...options.defaultValues,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSubmit(
|
||||||
|
data: GenerationFormValues,
|
||||||
|
selectedProfileId: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!selectedProfileId) {
|
||||||
|
toast({
|
||||||
|
title: 'No profile selected',
|
||||||
|
description: 'Please select a voice profile from the cards above.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsGenerating(true);
|
||||||
|
|
||||||
|
const modelName = `qwen-tts-${data.modelSize}`;
|
||||||
|
const displayName = data.modelSize === '1.7B' ? 'Qwen TTS 1.7B' : 'Qwen TTS 0.6B';
|
||||||
|
|
||||||
|
let isDownloading = false;
|
||||||
|
try {
|
||||||
|
const modelStatus = await apiClient.getModelStatus();
|
||||||
|
const model = modelStatus.models.find((m) => m.model_name === modelName);
|
||||||
|
|
||||||
|
if (model && !model.downloaded) {
|
||||||
|
setDownloadingModelName(modelName);
|
||||||
|
setDownloadingDisplayName(displayName);
|
||||||
|
isDownloading = true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check model status:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await generation.mutateAsync({
|
||||||
|
profile_id: selectedProfileId,
|
||||||
|
text: data.text,
|
||||||
|
language: data.language,
|
||||||
|
seed: data.seed,
|
||||||
|
model_size: data.modelSize,
|
||||||
|
instruct: data.instruct || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Generation complete!',
|
||||||
|
description: `Audio generated (${result.duration.toFixed(2)}s)`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const audioUrl = apiClient.getAudioUrl(result.id);
|
||||||
|
setAudioWithAutoPlay(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
options.onSuccess?.(result.id);
|
||||||
|
|
||||||
|
if (isDownloading) {
|
||||||
|
setDownloadingModelName(null);
|
||||||
|
setDownloadingDisplayName(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Generation failed',
|
||||||
|
description: error instanceof Error ? error.message : 'Failed to generate audio',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
setDownloadingModelName(null);
|
||||||
|
setDownloadingDisplayName(null);
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
handleSubmit,
|
||||||
|
isPending: generation.isPending,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -140,8 +140,8 @@ export function useModelDownloadToast({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
eventSource.onerror = () => {
|
eventSource.onerror = (error) => {
|
||||||
console.error('SSE error');
|
console.error('SSE error:', error);
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,24 @@ export function useDeleteSample() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useUpdateSample() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ sampleId, referenceText }: { sampleId: string; referenceText: string }) =>
|
||||||
|
apiClient.updateProfileSample(sampleId, referenceText),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['profiles', data.profile_id, 'samples'],
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['profiles', data.profile_id],
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useExportProfile() {
|
export function useExportProfile() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (profileId: string) => {
|
mutationFn: async (profileId: string) => {
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { StoryCreate, StoryItemCreate, StoryItemBatchUpdate, StoryItemReorder, StoryItemMove, StoryItemTrim, StoryItemSplit } from '@/lib/api/types';
|
||||||
|
import { isTauri } from '@/lib/tauri';
|
||||||
|
|
||||||
|
export function useStories() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['stories'],
|
||||||
|
queryFn: () => apiClient.listStories(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStory(storyId: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['stories', storyId],
|
||||||
|
queryFn: () => apiClient.getStory(storyId!),
|
||||||
|
enabled: !!storyId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateStory() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: StoryCreate) => apiClient.createStory(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateStory() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ storyId, data }: { storyId: string; data: StoryCreate }) =>
|
||||||
|
apiClient.updateStory(storyId, data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteStory() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (storyId: string) => apiClient.deleteStory(storyId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddStoryItem() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ storyId, data }: { storyId: string; data: StoryItemCreate }) =>
|
||||||
|
apiClient.addStoryItem(storyId, data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoveStoryItem() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ storyId, itemId }: { storyId: string; itemId: string }) =>
|
||||||
|
apiClient.removeStoryItem(storyId, itemId),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateStoryItemTimes() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ storyId, data }: { storyId: string; data: StoryItemBatchUpdate }) =>
|
||||||
|
apiClient.updateStoryItemTimes(storyId, data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReorderStoryItems() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ storyId, data }: { storyId: string; data: StoryItemReorder }) =>
|
||||||
|
apiClient.reorderStoryItems(storyId, data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMoveStoryItem() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemMove }) =>
|
||||||
|
apiClient.moveStoryItem(storyId, itemId, data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTrimStoryItem() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemTrim }) =>
|
||||||
|
apiClient.trimStoryItem(storyId, itemId, data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSplitStoryItem() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemSplit }) =>
|
||||||
|
apiClient.splitStoryItem(storyId, itemId, data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDuplicateStoryItem() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ storyId, itemId }: { storyId: string; itemId: string }) =>
|
||||||
|
apiClient.duplicateStoryItem(storyId, itemId),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExportStoryAudio() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ storyId, storyName }: { storyId: string; storyName: string }) => {
|
||||||
|
const blob = await apiClient.exportStoryAudio(storyId);
|
||||||
|
|
||||||
|
// Create safe filename
|
||||||
|
const safeName = storyName.substring(0, 50).replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||||
|
const filename = `${safeName || 'story'}.wav`;
|
||||||
|
|
||||||
|
if (isTauri()) {
|
||||||
|
// Use Tauri's native save dialog
|
||||||
|
try {
|
||||||
|
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { StoryItemDetail } from '@/lib/api/types';
|
||||||
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
|
|
||||||
|
interface ActiveSource {
|
||||||
|
source: AudioBufferSourceNode;
|
||||||
|
itemId: string;
|
||||||
|
generationId: string;
|
||||||
|
startTimeMs: number;
|
||||||
|
endTimeMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing timecode-based story playback using Web Audio API.
|
||||||
|
* Supports multiple simultaneous audio sources for overlapping clips on different tracks.
|
||||||
|
* Uses AudioContext for sample-accurate timing synchronization.
|
||||||
|
*/
|
||||||
|
export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
||||||
|
const isPlaying = useStoryStore((state) => state.isPlaying);
|
||||||
|
const playbackItems = useStoryStore((state) => state.playbackItems);
|
||||||
|
const playbackStartContextTime = useStoryStore((state) => state.playbackStartContextTime);
|
||||||
|
const playbackStartStoryTime = useStoryStore((state) => state.playbackStartStoryTime);
|
||||||
|
const setPlaybackTiming = useStoryStore((state) => state.setPlaybackTiming);
|
||||||
|
|
||||||
|
// AudioContext instance (created once)
|
||||||
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
|
// Master gain for volume control
|
||||||
|
const masterGainRef = useRef<GainNode | null>(null);
|
||||||
|
// Preloaded AudioBuffers by generation_id (audio file is shared between split clips)
|
||||||
|
const audioBuffersRef = useRef<Map<string, AudioBuffer>>(new Map());
|
||||||
|
// Currently playing AudioBufferSourceNodes by item.id (unique per clip)
|
||||||
|
const activeSourcesRef = useRef<Map<string, ActiveSource>>(new Map());
|
||||||
|
// Animation frame for syncing visual playhead
|
||||||
|
const animationFrameRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Get or create AudioContext and audio graph
|
||||||
|
const getAudioContext = useCallback(() => {
|
||||||
|
if (!audioContextRef.current) {
|
||||||
|
audioContextRef.current = new AudioContext();
|
||||||
|
console.log(
|
||||||
|
'[StoryPlayback] Created AudioContext, sample rate:',
|
||||||
|
audioContextRef.current.sampleRate,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create master gain node for volume control
|
||||||
|
masterGainRef.current = audioContextRef.current.createGain();
|
||||||
|
masterGainRef.current.gain.value = 1;
|
||||||
|
masterGainRef.current.connect(audioContextRef.current.destination);
|
||||||
|
}
|
||||||
|
// Resume context if suspended (browser autoplay policy)
|
||||||
|
if (audioContextRef.current.state === 'suspended') {
|
||||||
|
audioContextRef.current.resume().catch(() => {
|
||||||
|
// Ignore resume errors
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return audioContextRef.current;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Stop a source by item id
|
||||||
|
const stopSource = useCallback((itemId: string) => {
|
||||||
|
const activeSource = activeSourcesRef.current.get(itemId);
|
||||||
|
if (activeSource) {
|
||||||
|
try {
|
||||||
|
activeSource.source.stop();
|
||||||
|
} catch {
|
||||||
|
// Source may have already stopped
|
||||||
|
}
|
||||||
|
activeSourcesRef.current.delete(itemId);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Preload audio files as AudioBuffers
|
||||||
|
useEffect(() => {
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
// Clear preloaded buffers when no items
|
||||||
|
audioBuffersRef.current.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIds = new Set(items.map((item) => item.generation_id));
|
||||||
|
const audioContext = getAudioContext();
|
||||||
|
|
||||||
|
// Remove buffers for items that no longer exist
|
||||||
|
for (const [id] of audioBuffersRef.current) {
|
||||||
|
if (!currentIds.has(id)) {
|
||||||
|
audioBuffersRef.current.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preload audio for new items
|
||||||
|
const preloadPromises: Promise<void>[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (!audioBuffersRef.current.has(item.generation_id)) {
|
||||||
|
const audioUrl = apiClient.getAudioUrl(item.generation_id);
|
||||||
|
console.log('[StoryPlayback] Preloading audio buffer:', item.generation_id);
|
||||||
|
|
||||||
|
const preloadPromise = fetch(audioUrl)
|
||||||
|
.then((response) => response.arrayBuffer())
|
||||||
|
.then((arrayBuffer) => audioContext.decodeAudioData(arrayBuffer))
|
||||||
|
.then((audioBuffer) => {
|
||||||
|
audioBuffersRef.current.set(item.generation_id, audioBuffer);
|
||||||
|
console.log(
|
||||||
|
'[StoryPlayback] Preloaded buffer:',
|
||||||
|
item.generation_id,
|
||||||
|
'duration:',
|
||||||
|
audioBuffer.duration,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[StoryPlayback] Failed to preload audio:', item.generation_id, err);
|
||||||
|
});
|
||||||
|
|
||||||
|
preloadPromises.push(preloadPromise);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all(preloadPromises).then(() => {
|
||||||
|
console.log('[StoryPlayback] Preloaded', audioBuffersRef.current.size, 'audio buffers');
|
||||||
|
});
|
||||||
|
}, [items, getAudioContext]);
|
||||||
|
|
||||||
|
// Cleanup AudioContext on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
// Stop all sources
|
||||||
|
for (const [itemId] of activeSourcesRef.current) {
|
||||||
|
stopSource(itemId);
|
||||||
|
}
|
||||||
|
activeSourcesRef.current.clear();
|
||||||
|
|
||||||
|
// Clean up audio graph
|
||||||
|
if (masterGainRef.current) {
|
||||||
|
masterGainRef.current.disconnect();
|
||||||
|
masterGainRef.current = null;
|
||||||
|
}
|
||||||
|
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
|
||||||
|
audioContextRef.current.close().catch(() => {
|
||||||
|
// Ignore errors when closing
|
||||||
|
});
|
||||||
|
audioContextRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (animationFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [stopSource]);
|
||||||
|
|
||||||
|
// Find ALL items that should be playing at a given story time
|
||||||
|
const findActiveItems = useCallback(
|
||||||
|
(storyTimeMs: number, itemList: StoryItemDetail[]): StoryItemDetail[] => {
|
||||||
|
return itemList.filter((item) => {
|
||||||
|
const itemStart = item.start_time_ms;
|
||||||
|
// Use effective duration (accounting for trims)
|
||||||
|
const trimStartMs = item.trim_start_ms || 0;
|
||||||
|
const trimEndMs = item.trim_end_ms || 0;
|
||||||
|
const effectiveDurationMs = item.duration * 1000 - trimStartMs - trimEndMs;
|
||||||
|
const itemEnd = item.start_time_ms + effectiveDurationMs;
|
||||||
|
return storyTimeMs >= itemStart && storyTimeMs < itemEnd;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert AudioContext time to story time (ms)
|
||||||
|
const contextTimeToStoryTime = useCallback(
|
||||||
|
(contextTime: number): number => {
|
||||||
|
if (playbackStartContextTime === null || playbackStartStoryTime === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const elapsedContextTime = contextTime - playbackStartContextTime;
|
||||||
|
return playbackStartStoryTime + elapsedContextTime * 1000;
|
||||||
|
},
|
||||||
|
[playbackStartContextTime, playbackStartStoryTime],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert story time (ms) to AudioContext time
|
||||||
|
const storyTimeToContextTime = useCallback(
|
||||||
|
(storyTimeMs: number): number => {
|
||||||
|
if (playbackStartContextTime === null || playbackStartStoryTime === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const elapsedStoryTime = (storyTimeMs - playbackStartStoryTime) / 1000;
|
||||||
|
return playbackStartContextTime + elapsedStoryTime;
|
||||||
|
},
|
||||||
|
[playbackStartContextTime, playbackStartStoryTime],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stop all sources
|
||||||
|
const stopAllSources = useCallback(() => {
|
||||||
|
console.log('[StoryPlayback] Stopping all sources');
|
||||||
|
for (const [itemId] of activeSourcesRef.current) {
|
||||||
|
stopSource(itemId);
|
||||||
|
}
|
||||||
|
activeSourcesRef.current.clear();
|
||||||
|
}, [stopSource]);
|
||||||
|
|
||||||
|
// Schedule playback for all items that should be playing
|
||||||
|
const schedulePlayback = useCallback(
|
||||||
|
(storyTimeMs: number, itemList: StoryItemDetail[]) => {
|
||||||
|
const audioContext = getAudioContext();
|
||||||
|
const currentContextTime = audioContext.currentTime;
|
||||||
|
|
||||||
|
// Find all items that should be playing
|
||||||
|
const shouldBePlaying = findActiveItems(storyTimeMs, itemList);
|
||||||
|
const shouldBePlayingIds = new Set(shouldBePlaying.map((item) => item.id));
|
||||||
|
|
||||||
|
// Stop sources that shouldn't be playing anymore
|
||||||
|
for (const [itemId] of activeSourcesRef.current) {
|
||||||
|
if (!shouldBePlayingIds.has(itemId)) {
|
||||||
|
stopSource(itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule new sources for items that should be playing
|
||||||
|
for (const item of shouldBePlaying) {
|
||||||
|
if (!activeSourcesRef.current.has(item.id)) {
|
||||||
|
const buffer = audioBuffersRef.current.get(item.generation_id);
|
||||||
|
if (!buffer) {
|
||||||
|
console.warn('[StoryPlayback] Buffer not loaded for:', item.generation_id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate when this item should start in AudioContext time
|
||||||
|
const itemStartContextTime = storyTimeToContextTime(item.start_time_ms);
|
||||||
|
|
||||||
|
// Calculate effective duration and trim offsets
|
||||||
|
const trimStartSec = (item.trim_start_ms || 0) / 1000;
|
||||||
|
const trimEndSec = (item.trim_end_ms || 0) / 1000;
|
||||||
|
const effectiveDuration = item.duration - trimStartSec - trimEndSec;
|
||||||
|
const itemEndStoryTime = item.start_time_ms + effectiveDuration * 1000;
|
||||||
|
|
||||||
|
// Calculate offset into the buffer (if seeking mid-way)
|
||||||
|
// Offset is relative to the trimmed start of the clip
|
||||||
|
const offsetIntoEffectiveClip = Math.max(0, (storyTimeMs - item.start_time_ms) / 1000);
|
||||||
|
const offsetIntoBuffer = trimStartSec + offsetIntoEffectiveClip;
|
||||||
|
const duration = effectiveDuration - offsetIntoEffectiveClip;
|
||||||
|
|
||||||
|
// If the item should have already started, schedule it to start immediately
|
||||||
|
const startAtContextTime = Math.max(currentContextTime, itemStartContextTime);
|
||||||
|
|
||||||
|
console.log('[StoryPlayback] Scheduling source:', {
|
||||||
|
itemId: item.id,
|
||||||
|
generationId: item.generation_id,
|
||||||
|
storyTimeMs,
|
||||||
|
itemStart: item.start_time_ms,
|
||||||
|
offsetIntoBuffer,
|
||||||
|
startAtContextTime,
|
||||||
|
duration,
|
||||||
|
});
|
||||||
|
|
||||||
|
const source = audioContext.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
source.connect(masterGainRef.current || audioContext.destination);
|
||||||
|
|
||||||
|
const activeSource: ActiveSource = {
|
||||||
|
source,
|
||||||
|
itemId: item.id,
|
||||||
|
generationId: item.generation_id,
|
||||||
|
startTimeMs: item.start_time_ms,
|
||||||
|
endTimeMs: itemEndStoryTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
activeSourcesRef.current.set(item.id, activeSource);
|
||||||
|
|
||||||
|
// Schedule playback
|
||||||
|
source.start(startAtContextTime, offsetIntoBuffer, duration);
|
||||||
|
|
||||||
|
// Clean up when source ends
|
||||||
|
source.onended = () => {
|
||||||
|
console.log('[StoryPlayback] Source ended:', item.id);
|
||||||
|
activeSourcesRef.current.delete(item.id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getAudioContext, findActiveItems, storyTimeToContextTime, stopSource],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync visual playhead from AudioContext time
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying || playbackStartContextTime === null || playbackStartStoryTime === null) {
|
||||||
|
if (animationFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioContext = getAudioContext();
|
||||||
|
const itemList = playbackItems || [];
|
||||||
|
|
||||||
|
const syncPlayhead = () => {
|
||||||
|
if (!useStoryStore.getState().isPlaying) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentContextTime = audioContext.currentTime;
|
||||||
|
const currentStoryTime = contextTimeToStoryTime(currentContextTime);
|
||||||
|
const totalDuration = useStoryStore.getState().totalDurationMs;
|
||||||
|
|
||||||
|
// Update store with current story time
|
||||||
|
useStoryStore.setState({ currentTimeMs: Math.min(currentStoryTime, totalDuration) });
|
||||||
|
|
||||||
|
// Schedule any items that should be playing
|
||||||
|
schedulePlayback(currentStoryTime, itemList);
|
||||||
|
|
||||||
|
// Check if we've reached the end
|
||||||
|
if (currentStoryTime >= totalDuration) {
|
||||||
|
// Check if all sources have ended
|
||||||
|
if (activeSourcesRef.current.size === 0) {
|
||||||
|
console.log('[StoryPlayback] Reached end');
|
||||||
|
useStoryStore.getState().stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue sync loop
|
||||||
|
animationFrameRef.current = requestAnimationFrame(syncPlayhead);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial sync
|
||||||
|
const currentContextTime = audioContext.currentTime;
|
||||||
|
const currentStoryTime = contextTimeToStoryTime(currentContextTime);
|
||||||
|
schedulePlayback(currentStoryTime, itemList);
|
||||||
|
|
||||||
|
// Start sync loop
|
||||||
|
animationFrameRef.current = requestAnimationFrame(syncPlayhead);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
isPlaying,
|
||||||
|
playbackItems,
|
||||||
|
playbackStartContextTime,
|
||||||
|
playbackStartStoryTime,
|
||||||
|
getAudioContext,
|
||||||
|
contextTimeToStoryTime,
|
||||||
|
schedulePlayback,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Handle play/pause changes - stop sources when paused
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying) {
|
||||||
|
console.log('[StoryPlayback] Stopping playback');
|
||||||
|
stopAllSources();
|
||||||
|
}
|
||||||
|
}, [isPlaying, stopAllSources]);
|
||||||
|
|
||||||
|
// Handle seek - reset timing anchors when they become null (triggered by seek)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying || !playbackItems || playbackItems.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only run when timing anchors are null (after a seek)
|
||||||
|
if (playbackStartContextTime !== null && playbackStartStoryTime !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioContext = getAudioContext();
|
||||||
|
const currentContextTime = audioContext.currentTime;
|
||||||
|
const currentStoryTime = useStoryStore.getState().currentTimeMs;
|
||||||
|
|
||||||
|
console.log('[StoryPlayback] Setting timing anchors after seek:', {
|
||||||
|
contextTime: currentContextTime,
|
||||||
|
storyTime: currentStoryTime,
|
||||||
|
});
|
||||||
|
setPlaybackTiming(currentContextTime, currentStoryTime);
|
||||||
|
|
||||||
|
// Stop all existing sources and reschedule from new position
|
||||||
|
stopAllSources();
|
||||||
|
schedulePlayback(currentStoryTime, playbackItems);
|
||||||
|
}, [
|
||||||
|
isPlaying,
|
||||||
|
playbackItems,
|
||||||
|
playbackStartContextTime,
|
||||||
|
playbackStartStoryTime,
|
||||||
|
getAudioContext,
|
||||||
|
stopAllSources,
|
||||||
|
schedulePlayback,
|
||||||
|
setPlaybackTiming,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ interface UseSystemAudioCaptureOptions {
|
|||||||
* Uses ScreenCaptureKit on macOS and WASAPI loopback on Windows.
|
* Uses ScreenCaptureKit on macOS and WASAPI loopback on Windows.
|
||||||
*/
|
*/
|
||||||
export function useSystemAudioCapture({
|
export function useSystemAudioCapture({
|
||||||
maxDurationSeconds = 30,
|
maxDurationSeconds = 29,
|
||||||
onRecordingComplete,
|
onRecordingComplete,
|
||||||
}: UseSystemAudioCaptureOptions = {}) {
|
}: UseSystemAudioCaptureOptions = {}) {
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { LanguageCode } from '@/lib/constants/languages';
|
||||||
|
|
||||||
export function useTranscription() {
|
export function useTranscription() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ file, language }: { file: File; language?: 'en' | 'zh' }) =>
|
mutationFn: ({ file, language }: { file: File; language?: LanguageCode }) =>
|
||||||
apiClient.transcribeAudio(file, language),
|
apiClient.transcribeAudio(file, language),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,41 @@ export function formatAudioDuration(seconds: number): string {
|
|||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audio duration from a File.
|
||||||
|
* If the file has a recordedDuration property (from recording hooks),
|
||||||
|
* use that instead of trying to read metadata. This fixes issues on Windows
|
||||||
|
* where WebM files from MediaRecorder don't have proper duration metadata.
|
||||||
|
*/
|
||||||
|
export async function getAudioDuration(
|
||||||
|
file: File & { recordedDuration?: number },
|
||||||
|
): Promise<number> {
|
||||||
|
if (file.recordedDuration !== undefined && Number.isFinite(file.recordedDuration)) {
|
||||||
|
return file.recordedDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const audio = new Audio();
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
|
||||||
|
audio.addEventListener('loadedmetadata', () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
if (Number.isFinite(audio.duration) && audio.duration > 0) {
|
||||||
|
resolve(audio.duration);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Audio file has invalid duration metadata'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
audio.addEventListener('error', () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(new Error('Failed to load audio file'));
|
||||||
|
});
|
||||||
|
|
||||||
|
audio.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert any audio blob to WAV format using Web Audio API.
|
* Convert any audio blob to WAV format using Web Audio API.
|
||||||
* This ensures compatibility without requiring ffmpeg on the backend.
|
* This ensures compatibility without requiring ffmpeg on the backend.
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
const DEBUG = import.meta.env.DEV;
|
||||||
|
|
||||||
|
export const debug = {
|
||||||
|
log: (...args: unknown[]) => {
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log(...args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (...args: unknown[]) => {
|
||||||
|
if (DEBUG) {
|
||||||
|
console.error(...args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
warn: (...args: unknown[]) => {
|
||||||
|
if (DEBUG) {
|
||||||
|
console.warn(...args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router';
|
||||||
|
import { AppFrame } from '@/components/AppFrame/AppFrame';
|
||||||
|
import { AudioTab } from '@/components/AudioTab/AudioTab';
|
||||||
|
import { MainEditor } from '@/components/MainEditor/MainEditor';
|
||||||
|
import { ModelsTab } from '@/components/ModelsTab/ModelsTab';
|
||||||
|
import { ServerTab } from '@/components/ServerTab/ServerTab';
|
||||||
|
import { Sidebar } from '@/components/Sidebar';
|
||||||
|
import { StoriesTab } from '@/components/StoriesTab/StoriesTab';
|
||||||
|
import { Toaster } from '@/components/ui/toaster';
|
||||||
|
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
|
||||||
|
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||||
|
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
|
||||||
|
import { isMacOS } from '@/lib/tauri';
|
||||||
|
|
||||||
|
// Root layout component
|
||||||
|
function RootLayout() {
|
||||||
|
// Monitor active downloads/generations and show toasts for them
|
||||||
|
const activeDownloads = useRestoreActiveTasks();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppFrame>
|
||||||
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||||
|
<Sidebar isMacOS={isMacOS()} />
|
||||||
|
|
||||||
|
<main className="flex-1 ml-20 overflow-hidden flex flex-col">
|
||||||
|
<div className="container mx-auto px-8 max-w-[1800px] h-full overflow-hidden flex flex-col">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show download toasts for any active downloads (from anywhere) */}
|
||||||
|
{activeDownloads.map((download) => {
|
||||||
|
const displayName = MODEL_DISPLAY_NAMES[download.model_name] || download.model_name;
|
||||||
|
return (
|
||||||
|
<DownloadToastRestorer
|
||||||
|
key={download.model_name}
|
||||||
|
modelName={download.model_name}
|
||||||
|
displayName={displayName}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Toaster />
|
||||||
|
</AppFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that restores a download toast for a specific model.
|
||||||
|
*/
|
||||||
|
function DownloadToastRestorer({
|
||||||
|
modelName,
|
||||||
|
displayName,
|
||||||
|
}: {
|
||||||
|
modelName: string;
|
||||||
|
displayName: string;
|
||||||
|
}) {
|
||||||
|
// Use the download toast hook to restore the toast
|
||||||
|
useModelDownloadToast({
|
||||||
|
modelName,
|
||||||
|
displayName,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root route with layout
|
||||||
|
const rootRoute = createRootRoute({
|
||||||
|
component: RootLayout,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Index route (main/generate)
|
||||||
|
const indexRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/',
|
||||||
|
component: MainEditor,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stories route
|
||||||
|
const storiesRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/stories',
|
||||||
|
component: StoriesTab,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Voices route
|
||||||
|
const voicesRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/voices',
|
||||||
|
component: VoicesTab,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audio route
|
||||||
|
const audioRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/audio',
|
||||||
|
component: AudioTab,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Models route
|
||||||
|
const modelsRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/models',
|
||||||
|
component: ModelsTab,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Server route
|
||||||
|
const serverRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/server',
|
||||||
|
component: ServerTab,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route tree
|
||||||
|
const routeTree = rootRoute.addChildren([
|
||||||
|
indexRoute,
|
||||||
|
storiesRoute,
|
||||||
|
voicesRoute,
|
||||||
|
audioRoute,
|
||||||
|
modelsRoute,
|
||||||
|
serverRoute,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create router
|
||||||
|
export const router = createRouter({ routeTree });
|
||||||
|
|
||||||
|
// Register router for type safety
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface Register {
|
||||||
|
router: typeof router;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,8 +11,11 @@ interface PlayerState {
|
|||||||
volume: number;
|
volume: number;
|
||||||
isLooping: boolean;
|
isLooping: boolean;
|
||||||
shouldRestart: boolean;
|
shouldRestart: boolean;
|
||||||
|
shouldAutoPlay: boolean;
|
||||||
|
onFinish: (() => void) | null;
|
||||||
|
|
||||||
setAudio: (url: string, id: string, profileId: string | null, title?: string) => void;
|
setAudio: (url: string, id: string, profileId: string | null, title?: string) => void;
|
||||||
|
setAudioWithAutoPlay: (url: string, id: string, profileId: string | null, title?: string) => void;
|
||||||
setIsPlaying: (playing: boolean) => void;
|
setIsPlaying: (playing: boolean) => void;
|
||||||
setCurrentTime: (time: number) => void;
|
setCurrentTime: (time: number) => void;
|
||||||
setDuration: (duration: number) => void;
|
setDuration: (duration: number) => void;
|
||||||
@@ -20,6 +23,8 @@ interface PlayerState {
|
|||||||
toggleLoop: () => void;
|
toggleLoop: () => void;
|
||||||
restartCurrentAudio: () => void;
|
restartCurrentAudio: () => void;
|
||||||
clearRestartFlag: () => void;
|
clearRestartFlag: () => void;
|
||||||
|
clearAutoPlayFlag: () => void;
|
||||||
|
setOnFinish: (callback: (() => void) | null) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +39,8 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|||||||
volume: 1,
|
volume: 1,
|
||||||
isLooping: false,
|
isLooping: false,
|
||||||
shouldRestart: false,
|
shouldRestart: false,
|
||||||
|
shouldAutoPlay: false,
|
||||||
|
onFinish: null,
|
||||||
|
|
||||||
setAudio: (url, id, profileId, title) =>
|
setAudio: (url, id, profileId, title) =>
|
||||||
set({
|
set({
|
||||||
@@ -44,6 +51,18 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
shouldRestart: false,
|
shouldRestart: false,
|
||||||
|
shouldAutoPlay: false,
|
||||||
|
}),
|
||||||
|
setAudioWithAutoPlay: (url, id, profileId, title) =>
|
||||||
|
set({
|
||||||
|
audioUrl: url,
|
||||||
|
audioId: id,
|
||||||
|
profileId: profileId || null,
|
||||||
|
title: title || null,
|
||||||
|
currentTime: 0,
|
||||||
|
isPlaying: false,
|
||||||
|
shouldRestart: false,
|
||||||
|
shouldAutoPlay: true,
|
||||||
}),
|
}),
|
||||||
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
||||||
setCurrentTime: (time) => set({ currentTime: time }),
|
setCurrentTime: (time) => set({ currentTime: time }),
|
||||||
@@ -52,6 +71,8 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|||||||
toggleLoop: () => set((state) => ({ isLooping: !state.isLooping })),
|
toggleLoop: () => set((state) => ({ isLooping: !state.isLooping })),
|
||||||
restartCurrentAudio: () => set({ shouldRestart: true }),
|
restartCurrentAudio: () => set({ shouldRestart: true }),
|
||||||
clearRestartFlag: () => set({ shouldRestart: false }),
|
clearRestartFlag: () => set({ shouldRestart: false }),
|
||||||
|
clearAutoPlayFlag: () => set({ shouldAutoPlay: false }),
|
||||||
|
setOnFinish: (callback) => set({ onFinish: callback }),
|
||||||
reset: () =>
|
reset: () =>
|
||||||
set({
|
set({
|
||||||
audioUrl: null,
|
audioUrl: null,
|
||||||
@@ -63,5 +84,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|||||||
duration: 0,
|
duration: 0,
|
||||||
isLooping: false,
|
isLooping: false,
|
||||||
shouldRestart: false,
|
shouldRestart: false,
|
||||||
|
shouldAutoPlay: false,
|
||||||
|
onFinish: null,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { StoryItemDetail } from '@/lib/api/types';
|
||||||
|
|
||||||
|
interface StoryPlaybackState {
|
||||||
|
// Selection
|
||||||
|
selectedStoryId: string | null;
|
||||||
|
setSelectedStoryId: (id: string | null) => void;
|
||||||
|
selectedClipId: string | null;
|
||||||
|
setSelectedClipId: (id: string | null) => void;
|
||||||
|
|
||||||
|
// Track editor UI state
|
||||||
|
trackEditorHeight: number;
|
||||||
|
setTrackEditorHeight: (height: number) => void;
|
||||||
|
|
||||||
|
// Playback state
|
||||||
|
isPlaying: boolean;
|
||||||
|
currentTimeMs: number;
|
||||||
|
totalDurationMs: number;
|
||||||
|
playbackStoryId: string | null;
|
||||||
|
playbackItems: StoryItemDetail[] | null;
|
||||||
|
// Web Audio API timing (null when not playing)
|
||||||
|
playbackStartContextTime: number | null; // AudioContext.currentTime when playback started
|
||||||
|
playbackStartStoryTime: number | null; // Story time (ms) when playback started
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
play: (storyId: string, items: StoryItemDetail[]) => void;
|
||||||
|
pause: () => void;
|
||||||
|
stop: () => void;
|
||||||
|
seek: (timeMs: number) => void;
|
||||||
|
setPlaybackTiming: (contextTime: number, storyTime: number) => void; // Set timing anchors for Web Audio API
|
||||||
|
setActiveStory: (storyId: string, items: StoryItemDetail[], totalDurationMs: number) => void; // Activate story for seeking without playing
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TRACK_EDITOR_HEIGHT = 250;
|
||||||
|
|
||||||
|
export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
|
||||||
|
// Selection
|
||||||
|
selectedStoryId: null,
|
||||||
|
setSelectedStoryId: (id) => set({ selectedStoryId: id }),
|
||||||
|
selectedClipId: null,
|
||||||
|
setSelectedClipId: (id) => set({ selectedClipId: id }),
|
||||||
|
|
||||||
|
// Track editor UI state
|
||||||
|
trackEditorHeight: DEFAULT_TRACK_EDITOR_HEIGHT,
|
||||||
|
setTrackEditorHeight: (height) => set({ trackEditorHeight: height }),
|
||||||
|
|
||||||
|
// Playback state
|
||||||
|
isPlaying: false,
|
||||||
|
currentTimeMs: 0,
|
||||||
|
totalDurationMs: 0,
|
||||||
|
playbackStoryId: null,
|
||||||
|
playbackItems: null,
|
||||||
|
playbackStartContextTime: null,
|
||||||
|
playbackStartStoryTime: null,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
play: (storyId, items) => {
|
||||||
|
// Calculate total duration from items
|
||||||
|
const maxEndTimeMs = Math.max(
|
||||||
|
...items.map((item) => item.start_time_ms + item.duration * 1000),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the minimum start time (first item)
|
||||||
|
const minStartTimeMs = Math.min(...items.map((item) => item.start_time_ms), 0);
|
||||||
|
|
||||||
|
// If resuming the same story, keep position; otherwise start at first item
|
||||||
|
const currentState = get();
|
||||||
|
const shouldResume = currentState.playbackStoryId === storyId && currentState.currentTimeMs > 0;
|
||||||
|
const startTimeMs = shouldResume ? currentState.currentTimeMs : minStartTimeMs;
|
||||||
|
|
||||||
|
console.log('[StoryStore] Play called:', {
|
||||||
|
storyId,
|
||||||
|
itemCount: items.length,
|
||||||
|
items: items.map((i) => ({
|
||||||
|
id: i.generation_id,
|
||||||
|
start: i.start_time_ms,
|
||||||
|
duration: i.duration,
|
||||||
|
})),
|
||||||
|
maxEndTimeMs,
|
||||||
|
minStartTimeMs,
|
||||||
|
startTimeMs,
|
||||||
|
shouldResume,
|
||||||
|
});
|
||||||
|
|
||||||
|
set({
|
||||||
|
isPlaying: true,
|
||||||
|
playbackStoryId: storyId,
|
||||||
|
playbackItems: items,
|
||||||
|
totalDurationMs: maxEndTimeMs,
|
||||||
|
currentTimeMs: startTimeMs,
|
||||||
|
// Reset timing anchors - will be set fresh by the playback hook
|
||||||
|
playbackStartContextTime: null,
|
||||||
|
playbackStartStoryTime: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
pause: () => {
|
||||||
|
set({
|
||||||
|
isPlaying: false,
|
||||||
|
// Keep timing anchors so we can resume from same position
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
stop: () => {
|
||||||
|
set({
|
||||||
|
isPlaying: false,
|
||||||
|
currentTimeMs: 0,
|
||||||
|
playbackStoryId: null,
|
||||||
|
playbackItems: null,
|
||||||
|
totalDurationMs: 0,
|
||||||
|
playbackStartContextTime: null,
|
||||||
|
playbackStartStoryTime: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
seek: (timeMs) => {
|
||||||
|
const state = get();
|
||||||
|
const clampedTime = Math.max(0, Math.min(timeMs, state.totalDurationMs));
|
||||||
|
set({
|
||||||
|
currentTimeMs: clampedTime,
|
||||||
|
// Reset timing anchors - will be set by hook when playback resumes
|
||||||
|
playbackStartContextTime: null,
|
||||||
|
playbackStartStoryTime: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setPlaybackTiming: (contextTime, storyTime) => {
|
||||||
|
set({
|
||||||
|
playbackStartContextTime: contextTime,
|
||||||
|
playbackStartStoryTime: storyTime,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setActiveStory: (storyId, items, totalDurationMs) => {
|
||||||
|
const currentState = get();
|
||||||
|
// Only update if switching to a different story
|
||||||
|
if (currentState.playbackStoryId !== storyId) {
|
||||||
|
set({
|
||||||
|
playbackStoryId: storyId,
|
||||||
|
playbackItems: items,
|
||||||
|
totalDurationMs,
|
||||||
|
currentTimeMs: 0,
|
||||||
|
isPlaying: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,5 +1,18 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
// Draft state for the create voice profile form
|
||||||
|
export interface ProfileFormDraft {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
language: string;
|
||||||
|
referenceText: string;
|
||||||
|
sampleMode: 'upload' | 'record' | 'system';
|
||||||
|
// Note: File objects can't be persisted, so we store metadata
|
||||||
|
sampleFileName?: string;
|
||||||
|
sampleFileType?: string;
|
||||||
|
sampleFileData?: string; // Base64 encoded
|
||||||
|
}
|
||||||
|
|
||||||
interface UIStore {
|
interface UIStore {
|
||||||
// Sidebar
|
// Sidebar
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
@@ -18,6 +31,10 @@ interface UIStore {
|
|||||||
selectedProfileId: string | null;
|
selectedProfileId: string | null;
|
||||||
setSelectedProfileId: (id: string | null) => void;
|
setSelectedProfileId: (id: string | null) => void;
|
||||||
|
|
||||||
|
// Profile form draft (for persisting create voice modal state)
|
||||||
|
profileFormDraft: ProfileFormDraft | null;
|
||||||
|
setProfileFormDraft: (draft: ProfileFormDraft | null) => void;
|
||||||
|
|
||||||
// Theme
|
// Theme
|
||||||
theme: 'light' | 'dark';
|
theme: 'light' | 'dark';
|
||||||
setTheme: (theme: 'light' | 'dark') => void;
|
setTheme: (theme: 'light' | 'dark') => void;
|
||||||
@@ -38,6 +55,9 @@ export const useUIStore = create<UIStore>((set) => ({
|
|||||||
selectedProfileId: null,
|
selectedProfileId: null,
|
||||||
setSelectedProfileId: (id) => set({ selectedProfileId: id }),
|
setSelectedProfileId: (id) => set({ selectedProfileId: id }),
|
||||||
|
|
||||||
|
profileFormDraft: null,
|
||||||
|
setProfileFormDraft: (draft) => set({ profileFormDraft: draft }),
|
||||||
|
|
||||||
theme: 'light',
|
theme: 'light',
|
||||||
setTheme: (theme) => {
|
setTheme: (theme) => {
|
||||||
set({ theme });
|
set({ theme });
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
# Backend package
|
# Backend package
|
||||||
|
|
||||||
|
__version__ = "0.1.9"
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ def build_server():
|
|||||||
"""Build Python server as standalone binary."""
|
"""Build Python server as standalone binary."""
|
||||||
backend_dir = Path(__file__).parent
|
backend_dir = Path(__file__).parent
|
||||||
|
|
||||||
# Check for local editable qwen_tts install
|
|
||||||
local_qwen_path = Path.home() / 'Projects' / 'voice' / 'Qwen3-TTS'
|
|
||||||
|
|
||||||
# PyInstaller arguments
|
# PyInstaller arguments
|
||||||
args = [
|
args = [
|
||||||
'server.py', # Use server.py as entry point instead of main.py
|
'server.py', # Use server.py as entry point instead of main.py
|
||||||
@@ -21,10 +18,11 @@ def build_server():
|
|||||||
'--name', 'voicebox-server',
|
'--name', 'voicebox-server',
|
||||||
]
|
]
|
||||||
|
|
||||||
# Add local qwen_tts path if it exists (for editable installs)
|
# Add local qwen_tts path if specified (for editable installs)
|
||||||
if local_qwen_path.exists():
|
qwen_tts_path = os.getenv('QWEN_TTS_PATH')
|
||||||
args.extend(['--paths', str(local_qwen_path)])
|
if qwen_tts_path and Path(qwen_tts_path).exists():
|
||||||
print(f"Using local qwen_tts source from: {local_qwen_path}")
|
args.extend(['--paths', str(qwen_tts_path)])
|
||||||
|
print(f"Using local qwen_tts source from: {qwen_tts_path}")
|
||||||
|
|
||||||
# Add hidden imports
|
# Add hidden imports
|
||||||
args.extend([
|
args.extend([
|
||||||
|
|||||||
@@ -51,6 +51,31 @@ class Generation(Base):
|
|||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Story(Base):
|
||||||
|
"""Story database model."""
|
||||||
|
__tablename__ = "stories"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
description = Column(Text)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class StoryItem(Base):
|
||||||
|
"""Story item database model (links generations to stories)."""
|
||||||
|
__tablename__ = "story_items"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
story_id = Column(String, ForeignKey("stories.id"), nullable=False)
|
||||||
|
generation_id = Column(String, ForeignKey("generations.id"), nullable=False)
|
||||||
|
start_time_ms = Column(Integer, nullable=False, default=0) # Milliseconds from story start
|
||||||
|
track = Column(Integer, nullable=False, default=0) # Track number (0 = main track)
|
||||||
|
trim_start_ms = Column(Integer, nullable=False, default=0) # Milliseconds trimmed from start
|
||||||
|
trim_end_ms = Column(Integer, nullable=False, default=0) # Milliseconds trimmed from end
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
class Project(Base):
|
class Project(Base):
|
||||||
"""Audio studio project database model."""
|
"""Audio studio project database model."""
|
||||||
__tablename__ = "projects"
|
__tablename__ = "projects"
|
||||||
@@ -108,6 +133,10 @@ def init_db():
|
|||||||
)
|
)
|
||||||
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
# Run migrations before creating tables
|
||||||
|
_run_migrations(engine)
|
||||||
|
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
# Create default channel if it doesn't exist
|
# Create default channel if it doesn't exist
|
||||||
@@ -136,6 +165,119 @@ def init_db():
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_migrations(engine):
|
||||||
|
"""Run database migrations."""
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
|
||||||
|
inspector = inspect(engine)
|
||||||
|
|
||||||
|
# Check if story_items table exists
|
||||||
|
if 'story_items' not in inspector.get_table_names():
|
||||||
|
return # Table doesn't exist yet, will be created fresh
|
||||||
|
|
||||||
|
# Get columns in story_items table
|
||||||
|
columns = {col['name'] for col in inspector.get_columns('story_items')}
|
||||||
|
|
||||||
|
# Migration: Remove position column and ensure start_time_ms exists
|
||||||
|
# SQLite doesn't support DROP COLUMN easily, so we recreate the table
|
||||||
|
if 'position' in columns:
|
||||||
|
print("Migrating story_items: removing position column, using start_time_ms")
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
# Check if start_time_ms already exists
|
||||||
|
has_start_time = 'start_time_ms' in columns
|
||||||
|
|
||||||
|
if not has_start_time:
|
||||||
|
# First, add the new column temporarily
|
||||||
|
conn.execute(text("ALTER TABLE story_items ADD COLUMN start_time_ms INTEGER DEFAULT 0"))
|
||||||
|
|
||||||
|
# Calculate timecodes from position ordering
|
||||||
|
result = conn.execute(text("""
|
||||||
|
SELECT si.id, si.story_id, si.position, g.duration
|
||||||
|
FROM story_items si
|
||||||
|
JOIN generations g ON si.generation_id = g.id
|
||||||
|
ORDER BY si.story_id, si.position
|
||||||
|
"""))
|
||||||
|
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
current_story_id = None
|
||||||
|
current_time_ms = 0
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
item_id, story_id, position, duration = row
|
||||||
|
|
||||||
|
if story_id != current_story_id:
|
||||||
|
current_story_id = story_id
|
||||||
|
current_time_ms = 0
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
text("UPDATE story_items SET start_time_ms = :time WHERE id = :id"),
|
||||||
|
{"time": current_time_ms, "id": item_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
current_time_ms += int(duration * 1000) + 200
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Now recreate the table without the position column
|
||||||
|
# 1. Create new table
|
||||||
|
conn.execute(text("""
|
||||||
|
CREATE TABLE story_items_new (
|
||||||
|
id VARCHAR PRIMARY KEY,
|
||||||
|
story_id VARCHAR NOT NULL,
|
||||||
|
generation_id VARCHAR NOT NULL,
|
||||||
|
start_time_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME,
|
||||||
|
FOREIGN KEY (story_id) REFERENCES stories(id),
|
||||||
|
FOREIGN KEY (generation_id) REFERENCES generations(id)
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# 2. Copy data
|
||||||
|
conn.execute(text("""
|
||||||
|
INSERT INTO story_items_new (id, story_id, generation_id, start_time_ms, created_at)
|
||||||
|
SELECT id, story_id, generation_id, start_time_ms, created_at FROM story_items
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# 3. Drop old table
|
||||||
|
conn.execute(text("DROP TABLE story_items"))
|
||||||
|
|
||||||
|
# 4. Rename new table
|
||||||
|
conn.execute(text("ALTER TABLE story_items_new RENAME TO story_items"))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("Migrated story_items table to use start_time_ms (removed position column)")
|
||||||
|
|
||||||
|
# Migration: Add track column if it doesn't exist
|
||||||
|
# Re-check columns after potential position migration
|
||||||
|
columns = {col['name'] for col in inspector.get_columns('story_items')}
|
||||||
|
if 'track' not in columns:
|
||||||
|
print("Migrating story_items: adding track column")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("ALTER TABLE story_items ADD COLUMN track INTEGER NOT NULL DEFAULT 0"))
|
||||||
|
conn.commit()
|
||||||
|
print("Added track column to story_items")
|
||||||
|
|
||||||
|
# Migration: Add trim columns if they don't exist
|
||||||
|
# Re-check columns after potential track migration
|
||||||
|
columns = {col['name'] for col in inspector.get_columns('story_items')}
|
||||||
|
if 'trim_start_ms' not in columns:
|
||||||
|
print("Migrating story_items: adding trim_start_ms column")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("ALTER TABLE story_items ADD COLUMN trim_start_ms INTEGER NOT NULL DEFAULT 0"))
|
||||||
|
conn.commit()
|
||||||
|
print("Added trim_start_ms column to story_items")
|
||||||
|
|
||||||
|
columns = {col['name'] for col in inspector.get_columns('story_items')}
|
||||||
|
if 'trim_end_ms' not in columns:
|
||||||
|
print("Migrating story_items: adding trim_end_ms column")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("ALTER TABLE story_items ADD COLUMN trim_end_ms INTEGER NOT NULL DEFAULT 0"))
|
||||||
|
conn.commit()
|
||||||
|
print("Added trim_end_ms column to story_items")
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
"""Get database session (generator for dependency injection)."""
|
"""Get database session (generator for dependency injection)."""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
|||||||
+338
-28
@@ -11,6 +11,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import asyncio
|
||||||
import uvicorn
|
import uvicorn
|
||||||
import argparse
|
import argparse
|
||||||
import torch
|
import torch
|
||||||
@@ -18,8 +19,11 @@ import tempfile
|
|||||||
import io
|
import io
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import uuid
|
import uuid
|
||||||
|
import asyncio
|
||||||
|
import signal
|
||||||
|
import os
|
||||||
|
|
||||||
from . import database, models, profiles, history, tts, transcribe, config, export_import, channels
|
from . import database, models, profiles, history, tts, transcribe, config, export_import, channels, stories, __version__
|
||||||
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
|
||||||
@@ -27,7 +31,7 @@ from .utils.tasks import get_task_manager
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="voicebox API",
|
title="voicebox API",
|
||||||
description="Production-quality Qwen3-TTS voice cloning API",
|
description="Production-quality Qwen3-TTS voice cloning API",
|
||||||
version="0.1.0",
|
version=__version__,
|
||||||
)
|
)
|
||||||
|
|
||||||
# CORS middleware
|
# CORS middleware
|
||||||
@@ -47,16 +51,27 @@ app.add_middleware(
|
|||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
"""Root endpoint."""
|
"""Root endpoint."""
|
||||||
return {"message": "voicebox API", "version": "0.1.4"}
|
return {"message": "voicebox API", "version": __version__}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/shutdown")
|
||||||
|
async def shutdown():
|
||||||
|
"""Gracefully shutdown the server."""
|
||||||
|
async def shutdown_async():
|
||||||
|
await asyncio.sleep(0.1) # Give response time to send
|
||||||
|
os.kill(os.getpid(), signal.SIGTERM)
|
||||||
|
|
||||||
|
asyncio.create_task(shutdown_async())
|
||||||
|
return {"message": "Shutting down..."}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health", response_model=models.HealthResponse)
|
@app.get("/health", response_model=models.HealthResponse)
|
||||||
async def health():
|
async def health():
|
||||||
"""Health check endpoint."""
|
"""Health check endpoint."""
|
||||||
from huggingface_hub import hf_hub_download
|
from huggingface_hub import hf_hub_download, constants as hf_constants
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
import os
|
||||||
|
|
||||||
tts_model = tts.get_tts_model()
|
tts_model = tts.get_tts_model()
|
||||||
|
|
||||||
# Check for GPU availability (CUDA or MPS)
|
# Check for GPU availability (CUDA or MPS)
|
||||||
@@ -64,6 +79,12 @@ async def health():
|
|||||||
has_mps = hasattr(torch.backends, 'mps') and torch.backends.mps.is_available()
|
has_mps = hasattr(torch.backends, 'mps') and torch.backends.mps.is_available()
|
||||||
gpu_available = has_cuda or has_mps
|
gpu_available = has_cuda or has_mps
|
||||||
|
|
||||||
|
gpu_type = None
|
||||||
|
if has_cuda:
|
||||||
|
gpu_type = f"CUDA ({torch.cuda.get_device_name(0)})"
|
||||||
|
elif has_mps:
|
||||||
|
gpu_type = "MPS (Apple Silicon)"
|
||||||
|
|
||||||
vram_used = None
|
vram_used = None
|
||||||
if has_cuda:
|
if has_cuda:
|
||||||
vram_used = torch.cuda.memory_allocated() / 1024 / 1024 # MB
|
vram_used = torch.cuda.memory_allocated() / 1024 / 1024 # MB
|
||||||
@@ -101,8 +122,8 @@ async def health():
|
|||||||
model_downloaded = True
|
model_downloaded = True
|
||||||
break
|
break
|
||||||
except (ImportError, Exception):
|
except (ImportError, Exception):
|
||||||
# Method 2: Check cache directory
|
# Method 2: Check cache directory (using HuggingFace's OS-specific cache location)
|
||||||
cache_dir = os.path.expanduser("~/.cache/huggingface/hub")
|
cache_dir = hf_constants.HF_HUB_CACHE
|
||||||
repo_cache = Path(cache_dir) / "models--" + default_model_id.replace("/", "--")
|
repo_cache = Path(cache_dir) / "models--" + default_model_id.replace("/", "--")
|
||||||
if repo_cache.exists():
|
if repo_cache.exists():
|
||||||
has_model_files = (
|
has_model_files = (
|
||||||
@@ -121,6 +142,7 @@ async def health():
|
|||||||
model_downloaded=model_downloaded,
|
model_downloaded=model_downloaded,
|
||||||
model_size=model_size,
|
model_size=model_size,
|
||||||
gpu_available=gpu_available,
|
gpu_available=gpu_available,
|
||||||
|
gpu_type=gpu_type,
|
||||||
vram_used_mb=vram_used,
|
vram_used_mb=vram_used,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -261,6 +283,19 @@ async def delete_profile_sample(
|
|||||||
return {"message": "Sample deleted successfully"}
|
return {"message": "Sample deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/profiles/samples/{sample_id}", response_model=models.ProfileSampleResponse)
|
||||||
|
async def update_profile_sample(
|
||||||
|
sample_id: str,
|
||||||
|
data: models.ProfileSampleUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update a profile sample's reference text."""
|
||||||
|
sample = await profiles.update_profile_sample(sample_id, data.reference_text, db)
|
||||||
|
if not sample:
|
||||||
|
raise HTTPException(status_code=404, detail="Sample not found")
|
||||||
|
return sample
|
||||||
|
|
||||||
|
|
||||||
@app.get("/profiles/{profile_id}/export")
|
@app.get("/profiles/{profile_id}/export")
|
||||||
async def export_profile(
|
async def export_profile(
|
||||||
profile_id: str,
|
profile_id: str,
|
||||||
@@ -451,6 +486,36 @@ async def generate_speech(
|
|||||||
tts_model = tts.get_tts_model()
|
tts_model = tts.get_tts_model()
|
||||||
# Load the requested model size if different from current (async to not block)
|
# Load the requested model size if different from current (async to not block)
|
||||||
model_size = data.model_size or "1.7B"
|
model_size = data.model_size or "1.7B"
|
||||||
|
|
||||||
|
# Check if model needs to be downloaded first
|
||||||
|
model_path = tts_model._get_model_path(model_size)
|
||||||
|
if model_path.startswith("Qwen/"):
|
||||||
|
# Model not cached - check if it exists remotely or needs download
|
||||||
|
from huggingface_hub import constants as hf_constants
|
||||||
|
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_path.replace("/", "--"))
|
||||||
|
if not repo_cache.exists():
|
||||||
|
# Start download in background
|
||||||
|
model_name = f"qwen-tts-{model_size}"
|
||||||
|
|
||||||
|
async def download_model_background():
|
||||||
|
try:
|
||||||
|
await tts_model.load_model_async(model_size)
|
||||||
|
except Exception as e:
|
||||||
|
task_manager.error_download(model_name, str(e))
|
||||||
|
|
||||||
|
task_manager.start_download(model_name)
|
||||||
|
asyncio.create_task(download_model_background())
|
||||||
|
|
||||||
|
# Return 202 Accepted with download info
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=202,
|
||||||
|
detail={
|
||||||
|
"message": f"Model {model_size} is being downloaded. Please wait and try again.",
|
||||||
|
"model_name": model_name,
|
||||||
|
"downloading": True
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
await tts_model.load_model_async(model_size)
|
await tts_model.load_model_async(model_size)
|
||||||
audio, sample_rate = await tts_model.generate(
|
audio, sample_rate = await tts_model.generate(
|
||||||
data.text,
|
data.text,
|
||||||
@@ -684,6 +749,37 @@ async def transcribe_audio(
|
|||||||
|
|
||||||
# Transcribe
|
# Transcribe
|
||||||
whisper_model = transcribe.get_whisper_model()
|
whisper_model = transcribe.get_whisper_model()
|
||||||
|
|
||||||
|
# Check if Whisper model is downloaded (uses default size "base")
|
||||||
|
model_size = whisper_model.model_size
|
||||||
|
model_name = f"openai/whisper-{model_size}"
|
||||||
|
|
||||||
|
# Check if model is cached
|
||||||
|
from huggingface_hub import constants as hf_constants
|
||||||
|
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_name.replace("/", "--"))
|
||||||
|
if not repo_cache.exists():
|
||||||
|
# Start download in background
|
||||||
|
progress_model_name = f"whisper-{model_size}"
|
||||||
|
|
||||||
|
async def download_whisper_background():
|
||||||
|
try:
|
||||||
|
await whisper_model.load_model_async(model_size)
|
||||||
|
except Exception as e:
|
||||||
|
get_task_manager().error_download(progress_model_name, str(e))
|
||||||
|
|
||||||
|
get_task_manager().start_download(progress_model_name)
|
||||||
|
asyncio.create_task(download_whisper_background())
|
||||||
|
|
||||||
|
# Return 202 Accepted
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=202,
|
||||||
|
detail={
|
||||||
|
"message": f"Whisper model {model_size} is being downloaded. Please wait and try again.",
|
||||||
|
"model_name": progress_model_name,
|
||||||
|
"downloading": True
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
text = await whisper_model.transcribe(tmp_path, language)
|
text = await whisper_model.transcribe(tmp_path, language)
|
||||||
|
|
||||||
return models.TranscriptionResponse(
|
return models.TranscriptionResponse(
|
||||||
@@ -698,6 +794,209 @@ async def transcribe_audio(
|
|||||||
Path(tmp_path).unlink(missing_ok=True)
|
Path(tmp_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# STORY ENDPOINTS
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
@app.get("/stories", response_model=List[models.StoryResponse])
|
||||||
|
async def list_stories(db: Session = Depends(get_db)):
|
||||||
|
"""List all stories."""
|
||||||
|
return await stories.list_stories(db)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/stories", response_model=models.StoryResponse)
|
||||||
|
async def create_story(
|
||||||
|
data: models.StoryCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a new story."""
|
||||||
|
try:
|
||||||
|
return await stories.create_story(data, db)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/stories/{story_id}", response_model=models.StoryDetailResponse)
|
||||||
|
async def get_story(
|
||||||
|
story_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get a story with all its items."""
|
||||||
|
story = await stories.get_story(story_id, db)
|
||||||
|
if not story:
|
||||||
|
raise HTTPException(status_code=404, detail="Story not found")
|
||||||
|
return story
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/stories/{story_id}", response_model=models.StoryResponse)
|
||||||
|
async def update_story(
|
||||||
|
story_id: str,
|
||||||
|
data: models.StoryCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update a story."""
|
||||||
|
story = await stories.update_story(story_id, data, db)
|
||||||
|
if not story:
|
||||||
|
raise HTTPException(status_code=404, detail="Story not found")
|
||||||
|
return story
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/stories/{story_id}")
|
||||||
|
async def delete_story(
|
||||||
|
story_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Delete a story."""
|
||||||
|
success = await stories.delete_story(story_id, db)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail="Story not found")
|
||||||
|
return {"message": "Story deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/stories/{story_id}/items", response_model=models.StoryItemDetail)
|
||||||
|
async def add_story_item(
|
||||||
|
story_id: str,
|
||||||
|
data: models.StoryItemCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Add a generation to a story."""
|
||||||
|
item = await stories.add_item_to_story(story_id, data, db)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Story or generation not found")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/stories/{story_id}/items/{item_id}")
|
||||||
|
async def remove_story_item(
|
||||||
|
story_id: str,
|
||||||
|
item_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Remove a story item from a story."""
|
||||||
|
success = await stories.remove_item_from_story(story_id, item_id, db)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail="Story item not found")
|
||||||
|
return {"message": "Item removed successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/stories/{story_id}/items/times")
|
||||||
|
async def update_story_item_times(
|
||||||
|
story_id: str,
|
||||||
|
data: models.StoryItemBatchUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update story item timecodes."""
|
||||||
|
success = await stories.update_story_item_times(story_id, data, db)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid timecode update request")
|
||||||
|
return {"message": "Item timecodes updated successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/stories/{story_id}/items/reorder", response_model=List[models.StoryItemDetail])
|
||||||
|
async def reorder_story_items(
|
||||||
|
story_id: str,
|
||||||
|
data: models.StoryItemReorder,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Reorder story items and recalculate timecodes."""
|
||||||
|
items = await stories.reorder_story_items(story_id, data.generation_ids, db)
|
||||||
|
if items is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid reorder request - ensure all generation IDs belong to this story")
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/stories/{story_id}/items/{item_id}/move", response_model=models.StoryItemDetail)
|
||||||
|
async def move_story_item(
|
||||||
|
story_id: str,
|
||||||
|
item_id: str,
|
||||||
|
data: models.StoryItemMove,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Move a story item (update position and/or track)."""
|
||||||
|
item = await stories.move_story_item(story_id, item_id, data, db)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Story item not found")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/stories/{story_id}/items/{item_id}/trim", response_model=models.StoryItemDetail)
|
||||||
|
async def trim_story_item(
|
||||||
|
story_id: str,
|
||||||
|
item_id: str,
|
||||||
|
data: models.StoryItemTrim,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Trim a story item (update trim_start_ms and trim_end_ms)."""
|
||||||
|
item = await stories.trim_story_item(story_id, item_id, data, db)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Story item not found or invalid trim values")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/stories/{story_id}/items/{item_id}/split", response_model=List[models.StoryItemDetail])
|
||||||
|
async def split_story_item(
|
||||||
|
story_id: str,
|
||||||
|
item_id: str,
|
||||||
|
data: models.StoryItemSplit,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Split a story item at a given time, creating two clips."""
|
||||||
|
items = await stories.split_story_item(story_id, item_id, data, db)
|
||||||
|
if items is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Story item not found or invalid split point")
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/stories/{story_id}/items/{item_id}/duplicate", response_model=models.StoryItemDetail)
|
||||||
|
async def duplicate_story_item(
|
||||||
|
story_id: str,
|
||||||
|
item_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Duplicate a story item, creating a copy with all properties."""
|
||||||
|
item = await stories.duplicate_story_item(story_id, item_id, db)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Story item not found")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/stories/{story_id}/export-audio")
|
||||||
|
async def export_story_audio(
|
||||||
|
story_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Export story as single mixed audio file with timecode-based mixing."""
|
||||||
|
try:
|
||||||
|
# Get story to create filename
|
||||||
|
story = db.query(database.Story).filter_by(id=story_id).first()
|
||||||
|
if not story:
|
||||||
|
raise HTTPException(status_code=404, detail="Story not found")
|
||||||
|
|
||||||
|
# Export audio
|
||||||
|
audio_bytes = await stories.export_story_audio(story_id, db)
|
||||||
|
if not audio_bytes:
|
||||||
|
raise HTTPException(status_code=400, detail="Story has no audio items")
|
||||||
|
|
||||||
|
# Create safe filename
|
||||||
|
safe_name = "".join(c for c in story.name if c.isalnum() or c in (' ', '-', '_')).strip()
|
||||||
|
if not safe_name:
|
||||||
|
safe_name = "story"
|
||||||
|
filename = f"{safe_name}.wav"
|
||||||
|
|
||||||
|
# Return as streaming response
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(audio_bytes),
|
||||||
|
media_type="audio/wav",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{filename}"'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# FILE SERVING
|
# FILE SERVING
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -791,7 +1090,7 @@ async def get_model_progress(model_name: str):
|
|||||||
@app.get("/models/status", response_model=models.ModelStatusListResponse)
|
@app.get("/models/status", response_model=models.ModelStatusListResponse)
|
||||||
async def get_model_status():
|
async def get_model_status():
|
||||||
"""Get status of all available models."""
|
"""Get status of all available models."""
|
||||||
from huggingface_hub import hf_hub_download
|
from huggingface_hub import hf_hub_download, constants as hf_constants
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -894,10 +1193,10 @@ async def get_model_status():
|
|||||||
pass
|
pass
|
||||||
break
|
break
|
||||||
|
|
||||||
# Method 2: Fallback to checking cache directory directly
|
# Method 2: Fallback to checking cache directory directly (using HuggingFace's OS-specific cache location)
|
||||||
if not downloaded:
|
if not downloaded:
|
||||||
try:
|
try:
|
||||||
cache_dir = os.path.expanduser("~/.cache/huggingface/hub")
|
cache_dir = hf_constants.HF_HUB_CACHE
|
||||||
repo_cache = Path(cache_dir) / "models--" + config["hf_repo_id"].replace("/", "--")
|
repo_cache = Path(cache_dir) / "models--" + config["hf_repo_id"].replace("/", "--")
|
||||||
|
|
||||||
if repo_cache.exists():
|
if repo_cache.exists():
|
||||||
@@ -1006,22 +1305,22 @@ async def trigger_model_download(request: models.ModelDownloadRequest):
|
|||||||
|
|
||||||
config = model_configs[request.model_name]
|
config = model_configs[request.model_name]
|
||||||
|
|
||||||
try:
|
async def download_in_background():
|
||||||
# Start tracking download
|
"""Download model in background without blocking the HTTP request."""
|
||||||
task_manager.start_download(request.model_name)
|
try:
|
||||||
|
await asyncio.to_thread(config["load_func"])
|
||||||
# Trigger download by loading the model (which will download if not cached)
|
task_manager.complete_download(request.model_name)
|
||||||
# Run in background to avoid blocking
|
except Exception as e:
|
||||||
await asyncio.to_thread(config["load_func"])
|
task_manager.error_download(request.model_name, str(e))
|
||||||
|
|
||||||
# Mark download as complete
|
# Start tracking download
|
||||||
task_manager.complete_download(request.model_name)
|
task_manager.start_download(request.model_name)
|
||||||
|
|
||||||
return {"message": f"Model {request.model_name} download started"}
|
# Start download in background task (don't await)
|
||||||
except Exception as e:
|
asyncio.create_task(download_in_background())
|
||||||
# Mark download as failed
|
|
||||||
task_manager.error_download(request.model_name, str(e))
|
# Return immediately - frontend should poll progress endpoint
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
return {"message": f"Model {request.model_name} download started"}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/models/{model_name}")
|
@app.delete("/models/{model_name}")
|
||||||
@@ -1029,6 +1328,7 @@ async def delete_model(model_name: str):
|
|||||||
"""Delete a downloaded model from the HuggingFace cache."""
|
"""Delete a downloaded model from the HuggingFace cache."""
|
||||||
import shutil
|
import shutil
|
||||||
import os
|
import os
|
||||||
|
from huggingface_hub import constants as hf_constants
|
||||||
|
|
||||||
# Map model names to HuggingFace repo IDs
|
# Map model names to HuggingFace repo IDs
|
||||||
model_configs = {
|
model_configs = {
|
||||||
@@ -1081,8 +1381,8 @@ async def delete_model(model_name: str):
|
|||||||
if whisper_model.is_loaded() and whisper_model.model_size == config["model_size"]:
|
if whisper_model.is_loaded() and whisper_model.model_size == config["model_size"]:
|
||||||
transcribe.unload_whisper_model()
|
transcribe.unload_whisper_model()
|
||||||
|
|
||||||
# Find and delete the cache directory
|
# Find and delete the cache directory (using HuggingFace's OS-specific cache location)
|
||||||
cache_dir = os.path.expanduser("~/.cache/huggingface/hub")
|
cache_dir = hf_constants.HF_HUB_CACHE
|
||||||
repo_cache_dir = Path(cache_dir) / ("models--" + hf_repo_id.replace("/", "--"))
|
repo_cache_dir = Path(cache_dir) / ("models--" + hf_repo_id.replace("/", "--"))
|
||||||
|
|
||||||
# Check if the cache directory exists
|
# Check if the cache directory exists
|
||||||
@@ -1193,6 +1493,16 @@ async def startup_event():
|
|||||||
print(f"Database initialized at {database._db_path}")
|
print(f"Database initialized at {database._db_path}")
|
||||||
print(f"GPU available: {_get_gpu_status()}")
|
print(f"GPU available: {_get_gpu_status()}")
|
||||||
|
|
||||||
|
# Ensure HuggingFace cache directory exists
|
||||||
|
try:
|
||||||
|
from huggingface_hub import constants as hf_constants
|
||||||
|
cache_dir = Path(hf_constants.HF_HUB_CACHE)
|
||||||
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
print(f"HuggingFace cache directory: {cache_dir}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not create HuggingFace cache directory: {e}")
|
||||||
|
print("Model downloads may fail. Please ensure the directory exists and has write permissions.")
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
async def shutdown_event():
|
async def shutdown_event():
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ class ProfileSampleCreate(BaseModel):
|
|||||||
reference_text: str = Field(..., min_length=1, max_length=1000)
|
reference_text: str = Field(..., min_length=1, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileSampleUpdate(BaseModel):
|
||||||
|
"""Request model for updating a profile sample."""
|
||||||
|
reference_text: str = Field(..., min_length=1, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
class ProfileSampleResponse(BaseModel):
|
class ProfileSampleResponse(BaseModel):
|
||||||
"""Response model for profile sample."""
|
"""Response model for profile sample."""
|
||||||
id: str
|
id: str
|
||||||
@@ -118,6 +123,7 @@ class HealthResponse(BaseModel):
|
|||||||
model_downloaded: Optional[bool] = None # Whether model is cached/downloaded
|
model_downloaded: Optional[bool] = None # Whether model is cached/downloaded
|
||||||
model_size: Optional[str] = None # Current model size if loaded
|
model_size: Optional[str] = None # Current model size if loaded
|
||||||
gpu_available: bool
|
gpu_available: bool
|
||||||
|
gpu_type: Optional[str] = None # GPU type (CUDA, MPS, or None)
|
||||||
vram_used_mb: Optional[float] = None
|
vram_used_mb: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -193,3 +199,100 @@ class ChannelVoiceAssignment(BaseModel):
|
|||||||
class ProfileChannelAssignment(BaseModel):
|
class ProfileChannelAssignment(BaseModel):
|
||||||
"""Request model for assigning channels to a profile."""
|
"""Request model for assigning channels to a profile."""
|
||||||
channel_ids: List[str]
|
channel_ids: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
class StoryCreate(BaseModel):
|
||||||
|
"""Request model for creating a story."""
|
||||||
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
description: Optional[str] = Field(None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class StoryResponse(BaseModel):
|
||||||
|
"""Response model for story (list view)."""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: Optional[str]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
item_count: int = 0
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class StoryItemDetail(BaseModel):
|
||||||
|
"""Detail model for story item with generation info."""
|
||||||
|
id: str
|
||||||
|
story_id: str
|
||||||
|
generation_id: str
|
||||||
|
start_time_ms: int
|
||||||
|
track: int = 0
|
||||||
|
trim_start_ms: int = 0
|
||||||
|
trim_end_ms: int = 0
|
||||||
|
created_at: datetime
|
||||||
|
# Generation details
|
||||||
|
profile_id: str
|
||||||
|
profile_name: str
|
||||||
|
text: str
|
||||||
|
language: str
|
||||||
|
audio_path: str
|
||||||
|
duration: float
|
||||||
|
seed: Optional[int]
|
||||||
|
instruct: Optional[str]
|
||||||
|
generation_created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class StoryDetailResponse(BaseModel):
|
||||||
|
"""Response model for story with items."""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: Optional[str]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
items: List[StoryItemDetail] = []
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class StoryItemCreate(BaseModel):
|
||||||
|
"""Request model for adding a generation to a story."""
|
||||||
|
generation_id: str
|
||||||
|
start_time_ms: Optional[int] = None # If not provided, will be calculated automatically
|
||||||
|
track: Optional[int] = 0 # Track number (0 = main track)
|
||||||
|
|
||||||
|
|
||||||
|
class StoryItemUpdateTime(BaseModel):
|
||||||
|
"""Request model for updating a story item's timecode."""
|
||||||
|
generation_id: str
|
||||||
|
start_time_ms: int = Field(..., ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class StoryItemBatchUpdate(BaseModel):
|
||||||
|
"""Request model for batch updating story item timecodes."""
|
||||||
|
updates: List[StoryItemUpdateTime]
|
||||||
|
|
||||||
|
|
||||||
|
class StoryItemReorder(BaseModel):
|
||||||
|
"""Request model for reordering story items."""
|
||||||
|
generation_ids: List[str] = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class StoryItemMove(BaseModel):
|
||||||
|
"""Request model for moving a story item (position and/or track)."""
|
||||||
|
start_time_ms: int = Field(..., ge=0)
|
||||||
|
track: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class StoryItemTrim(BaseModel):
|
||||||
|
"""Request model for trimming a story item."""
|
||||||
|
trim_start_ms: int = Field(..., ge=0)
|
||||||
|
trim_end_ms: int = Field(..., ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class StoryItemSplit(BaseModel):
|
||||||
|
"""Request model for splitting a story item."""
|
||||||
|
split_time_ms: int = Field(..., ge=0) # Time within the clip to split at (relative to clip start)
|
||||||
|
|||||||
@@ -273,6 +273,33 @@ async def delete_profile_sample(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def update_profile_sample(
|
||||||
|
sample_id: str,
|
||||||
|
reference_text: str,
|
||||||
|
db: Session,
|
||||||
|
) -> Optional[ProfileSampleResponse]:
|
||||||
|
"""
|
||||||
|
Update a profile sample's reference text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sample_id: Sample ID
|
||||||
|
reference_text: Updated reference text
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated sample or None if not found
|
||||||
|
"""
|
||||||
|
sample = db.query(DBProfileSample).filter_by(id=sample_id).first()
|
||||||
|
if not sample:
|
||||||
|
return None
|
||||||
|
|
||||||
|
sample.reference_text = reference_text
|
||||||
|
db.commit()
|
||||||
|
db.refresh(sample)
|
||||||
|
|
||||||
|
return ProfileSampleResponse.model_validate(sample)
|
||||||
|
|
||||||
|
|
||||||
async def create_voice_prompt_for_profile(
|
async def create_voice_prompt_for_profile(
|
||||||
profile_id: str,
|
profile_id: str,
|
||||||
db: Session,
|
db: Session,
|
||||||
|
|||||||
@@ -0,0 +1,972 @@
|
|||||||
|
"""
|
||||||
|
Story management module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
import uuid
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
StoryCreate,
|
||||||
|
StoryResponse,
|
||||||
|
StoryDetailResponse,
|
||||||
|
StoryItemDetail,
|
||||||
|
StoryItemCreate,
|
||||||
|
StoryItemBatchUpdate,
|
||||||
|
StoryItemMove,
|
||||||
|
StoryItemTrim,
|
||||||
|
StoryItemSplit,
|
||||||
|
)
|
||||||
|
from .database import Story as DBStory, StoryItem as DBStoryItem, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
|
||||||
|
from .utils.audio import load_audio, save_audio
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
async def create_story(
|
||||||
|
data: StoryCreate,
|
||||||
|
db: Session,
|
||||||
|
) -> StoryResponse:
|
||||||
|
"""
|
||||||
|
Create a new story.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Story creation data
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created story
|
||||||
|
"""
|
||||||
|
db_story = DBStory(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(db_story)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_story)
|
||||||
|
|
||||||
|
# Get item count
|
||||||
|
item_count = db.query(func.count(DBStoryItem.id)).filter(
|
||||||
|
DBStoryItem.story_id == db_story.id
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
response = StoryResponse.model_validate(db_story)
|
||||||
|
response.item_count = item_count
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def list_stories(
|
||||||
|
db: Session,
|
||||||
|
) -> List[StoryResponse]:
|
||||||
|
"""
|
||||||
|
List all stories.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of stories with item counts
|
||||||
|
"""
|
||||||
|
stories = db.query(DBStory).order_by(DBStory.updated_at.desc()).all()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for story in stories:
|
||||||
|
item_count = db.query(func.count(DBStoryItem.id)).filter(
|
||||||
|
DBStoryItem.story_id == story.id
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
response = StoryResponse.model_validate(story)
|
||||||
|
response.item_count = item_count
|
||||||
|
result.append(response)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def get_story(
|
||||||
|
story_id: str,
|
||||||
|
db: Session,
|
||||||
|
) -> Optional[StoryDetailResponse]:
|
||||||
|
"""
|
||||||
|
Get a story with all its items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story_id: Story ID
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Story with items or None if not found
|
||||||
|
"""
|
||||||
|
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||||
|
if not story:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get all items ordered by start_time_ms
|
||||||
|
items = db.query(
|
||||||
|
DBStoryItem,
|
||||||
|
DBGeneration,
|
||||||
|
DBVoiceProfile.name.label('profile_name')
|
||||||
|
).join(
|
||||||
|
DBGeneration,
|
||||||
|
DBStoryItem.generation_id == DBGeneration.id
|
||||||
|
).join(
|
||||||
|
DBVoiceProfile,
|
||||||
|
DBGeneration.profile_id == DBVoiceProfile.id
|
||||||
|
).filter(
|
||||||
|
DBStoryItem.story_id == story_id
|
||||||
|
).order_by(DBStoryItem.start_time_ms).all()
|
||||||
|
|
||||||
|
# Build item details
|
||||||
|
item_details = []
|
||||||
|
for item, generation, profile_name in items:
|
||||||
|
item_detail = StoryItemDetail(
|
||||||
|
id=item.id,
|
||||||
|
story_id=item.story_id,
|
||||||
|
generation_id=item.generation_id,
|
||||||
|
start_time_ms=item.start_time_ms,
|
||||||
|
track=item.track,
|
||||||
|
trim_start_ms=getattr(item, 'trim_start_ms', 0),
|
||||||
|
trim_end_ms=getattr(item, 'trim_end_ms', 0),
|
||||||
|
created_at=item.created_at,
|
||||||
|
profile_id=generation.profile_id,
|
||||||
|
profile_name=profile_name,
|
||||||
|
text=generation.text,
|
||||||
|
language=generation.language,
|
||||||
|
audio_path=generation.audio_path,
|
||||||
|
duration=generation.duration,
|
||||||
|
seed=generation.seed,
|
||||||
|
instruct=generation.instruct,
|
||||||
|
generation_created_at=generation.created_at,
|
||||||
|
)
|
||||||
|
item_details.append(item_detail)
|
||||||
|
|
||||||
|
response = StoryDetailResponse.model_validate(story)
|
||||||
|
response.items = item_details
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def update_story(
|
||||||
|
story_id: str,
|
||||||
|
data: StoryCreate,
|
||||||
|
db: Session,
|
||||||
|
) -> Optional[StoryResponse]:
|
||||||
|
"""
|
||||||
|
Update a story.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story_id: Story ID
|
||||||
|
data: Update data
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated story or None if not found
|
||||||
|
"""
|
||||||
|
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||||
|
if not story:
|
||||||
|
return None
|
||||||
|
|
||||||
|
story.name = data.name
|
||||||
|
story.description = data.description
|
||||||
|
story.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(story)
|
||||||
|
|
||||||
|
# Get item count
|
||||||
|
item_count = db.query(func.count(DBStoryItem.id)).filter(
|
||||||
|
DBStoryItem.story_id == story.id
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
response = StoryResponse.model_validate(story)
|
||||||
|
response.item_count = item_count
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_story(
|
||||||
|
story_id: str,
|
||||||
|
db: Session,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Delete a story and all its items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story_id: Story ID
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if not found
|
||||||
|
"""
|
||||||
|
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||||
|
if not story:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Delete all items
|
||||||
|
db.query(DBStoryItem).filter_by(story_id=story_id).delete()
|
||||||
|
|
||||||
|
# Delete story
|
||||||
|
db.delete(story)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def add_item_to_story(
|
||||||
|
story_id: str,
|
||||||
|
data: StoryItemCreate,
|
||||||
|
db: Session,
|
||||||
|
) -> Optional[StoryItemDetail]:
|
||||||
|
"""
|
||||||
|
Add a generation to a story.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story_id: Story ID
|
||||||
|
data: Item creation data
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created item detail or None if story/generation not found
|
||||||
|
"""
|
||||||
|
# Verify story exists
|
||||||
|
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||||
|
if not story:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Verify generation exists
|
||||||
|
generation = db.query(DBGeneration).filter_by(id=data.generation_id).first()
|
||||||
|
if not generation:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if generation is already in story
|
||||||
|
existing = db.query(DBStoryItem).filter_by(
|
||||||
|
story_id=story_id,
|
||||||
|
generation_id=data.generation_id
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
# Return existing item
|
||||||
|
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||||
|
return StoryItemDetail(
|
||||||
|
id=existing.id,
|
||||||
|
story_id=existing.story_id,
|
||||||
|
generation_id=existing.generation_id,
|
||||||
|
start_time_ms=existing.start_time_ms,
|
||||||
|
track=existing.track,
|
||||||
|
trim_start_ms=getattr(existing, 'trim_start_ms', 0),
|
||||||
|
trim_end_ms=getattr(existing, 'trim_end_ms', 0),
|
||||||
|
created_at=existing.created_at,
|
||||||
|
profile_id=generation.profile_id,
|
||||||
|
profile_name=profile.name if profile else "Unknown",
|
||||||
|
text=generation.text,
|
||||||
|
language=generation.language,
|
||||||
|
audio_path=generation.audio_path,
|
||||||
|
duration=generation.duration,
|
||||||
|
seed=generation.seed,
|
||||||
|
instruct=generation.instruct,
|
||||||
|
generation_created_at=generation.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate start_time_ms if not provided
|
||||||
|
if data.start_time_ms is not None:
|
||||||
|
start_time_ms = data.start_time_ms
|
||||||
|
else:
|
||||||
|
# Find the maximum end time (start_time_ms + duration_ms) of existing items
|
||||||
|
existing_items = db.query(
|
||||||
|
DBStoryItem,
|
||||||
|
DBGeneration
|
||||||
|
).join(
|
||||||
|
DBGeneration,
|
||||||
|
DBStoryItem.generation_id == DBGeneration.id
|
||||||
|
).filter(
|
||||||
|
DBStoryItem.story_id == story_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if not existing_items:
|
||||||
|
# First item starts at 0
|
||||||
|
start_time_ms = 0
|
||||||
|
else:
|
||||||
|
max_end_time_ms = 0
|
||||||
|
for item, gen in existing_items:
|
||||||
|
item_end_ms = item.start_time_ms + int(gen.duration * 1000)
|
||||||
|
max_end_time_ms = max(max_end_time_ms, item_end_ms)
|
||||||
|
|
||||||
|
# Add 200ms gap after the last item
|
||||||
|
start_time_ms = max_end_time_ms + 200
|
||||||
|
|
||||||
|
# Get track from data or default to 0
|
||||||
|
track = data.track if data.track is not None else 0
|
||||||
|
|
||||||
|
# Create item
|
||||||
|
item = DBStoryItem(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
story_id=story_id,
|
||||||
|
generation_id=data.generation_id,
|
||||||
|
start_time_ms=start_time_ms,
|
||||||
|
track=track,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(item)
|
||||||
|
|
||||||
|
# Update story updated_at
|
||||||
|
story.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(item)
|
||||||
|
|
||||||
|
# Get profile name
|
||||||
|
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||||
|
|
||||||
|
return StoryItemDetail(
|
||||||
|
id=item.id,
|
||||||
|
story_id=item.story_id,
|
||||||
|
generation_id=item.generation_id,
|
||||||
|
start_time_ms=item.start_time_ms,
|
||||||
|
track=item.track,
|
||||||
|
trim_start_ms=getattr(item, 'trim_start_ms', 0),
|
||||||
|
trim_end_ms=getattr(item, 'trim_end_ms', 0),
|
||||||
|
created_at=item.created_at,
|
||||||
|
profile_id=generation.profile_id,
|
||||||
|
profile_name=profile.name if profile else "Unknown",
|
||||||
|
text=generation.text,
|
||||||
|
language=generation.language,
|
||||||
|
audio_path=generation.audio_path,
|
||||||
|
duration=generation.duration,
|
||||||
|
seed=generation.seed,
|
||||||
|
instruct=generation.instruct,
|
||||||
|
generation_created_at=generation.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def move_story_item(
|
||||||
|
story_id: str,
|
||||||
|
item_id: str,
|
||||||
|
data: StoryItemMove,
|
||||||
|
db: Session,
|
||||||
|
) -> Optional[StoryItemDetail]:
|
||||||
|
"""
|
||||||
|
Move a story item (update position and/or track).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story_id: Story ID
|
||||||
|
item_id: Story item ID
|
||||||
|
data: New position and track data
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated item detail or None if not found
|
||||||
|
"""
|
||||||
|
# Get the item
|
||||||
|
item = db.query(DBStoryItem).filter_by(
|
||||||
|
id=item_id,
|
||||||
|
story_id=story_id,
|
||||||
|
).first()
|
||||||
|
if not item:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get the generation
|
||||||
|
generation = db.query(DBGeneration).filter_by(id=item.generation_id).first()
|
||||||
|
if not generation:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Update position and track
|
||||||
|
item.start_time_ms = data.start_time_ms
|
||||||
|
item.track = data.track
|
||||||
|
|
||||||
|
# Update story updated_at
|
||||||
|
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||||
|
if story:
|
||||||
|
story.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(item)
|
||||||
|
|
||||||
|
# Get profile name
|
||||||
|
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||||
|
|
||||||
|
return StoryItemDetail(
|
||||||
|
id=item.id,
|
||||||
|
story_id=item.story_id,
|
||||||
|
generation_id=item.generation_id,
|
||||||
|
start_time_ms=item.start_time_ms,
|
||||||
|
track=item.track,
|
||||||
|
trim_start_ms=getattr(item, 'trim_start_ms', 0),
|
||||||
|
trim_end_ms=getattr(item, 'trim_end_ms', 0),
|
||||||
|
created_at=item.created_at,
|
||||||
|
profile_id=generation.profile_id,
|
||||||
|
profile_name=profile.name if profile else "Unknown",
|
||||||
|
text=generation.text,
|
||||||
|
language=generation.language,
|
||||||
|
audio_path=generation.audio_path,
|
||||||
|
duration=generation.duration,
|
||||||
|
seed=generation.seed,
|
||||||
|
instruct=generation.instruct,
|
||||||
|
generation_created_at=generation.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_item_from_story(
|
||||||
|
story_id: str,
|
||||||
|
item_id: str,
|
||||||
|
db: Session,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Remove a story item from a story.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story_id: Story ID
|
||||||
|
item_id: Story item ID to remove
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if removed, False if not found
|
||||||
|
"""
|
||||||
|
item = db.query(DBStoryItem).filter_by(
|
||||||
|
id=item_id,
|
||||||
|
story_id=story_id,
|
||||||
|
).first()
|
||||||
|
if not item:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Delete item
|
||||||
|
db.delete(item)
|
||||||
|
|
||||||
|
# Update story updated_at
|
||||||
|
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||||
|
if story:
|
||||||
|
story.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def trim_story_item(
|
||||||
|
story_id: str,
|
||||||
|
item_id: str,
|
||||||
|
data: StoryItemTrim,
|
||||||
|
db: Session,
|
||||||
|
) -> Optional[StoryItemDetail]:
|
||||||
|
"""
|
||||||
|
Trim a story item (update trim_start_ms and trim_end_ms).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story_id: Story ID
|
||||||
|
item_id: Story item ID
|
||||||
|
data: Trim data (trim_start_ms, trim_end_ms)
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated item detail or None if not found
|
||||||
|
"""
|
||||||
|
# Get the item
|
||||||
|
item = db.query(DBStoryItem).filter_by(
|
||||||
|
id=item_id,
|
||||||
|
story_id=story_id,
|
||||||
|
).first()
|
||||||
|
if not item:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get the generation
|
||||||
|
generation = db.query(DBGeneration).filter_by(id=item.generation_id).first()
|
||||||
|
if not generation:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Validate trim values don't exceed duration
|
||||||
|
max_duration_ms = int(generation.duration * 1000)
|
||||||
|
if data.trim_start_ms + data.trim_end_ms >= max_duration_ms:
|
||||||
|
return None # Invalid trim - would result in zero or negative duration
|
||||||
|
|
||||||
|
# Update trim values
|
||||||
|
item.trim_start_ms = data.trim_start_ms
|
||||||
|
item.trim_end_ms = data.trim_end_ms
|
||||||
|
|
||||||
|
# Update story updated_at
|
||||||
|
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||||
|
if story:
|
||||||
|
story.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(item)
|
||||||
|
|
||||||
|
# Get profile name
|
||||||
|
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||||
|
|
||||||
|
return StoryItemDetail(
|
||||||
|
id=item.id,
|
||||||
|
story_id=item.story_id,
|
||||||
|
generation_id=item.generation_id,
|
||||||
|
start_time_ms=item.start_time_ms,
|
||||||
|
track=item.track,
|
||||||
|
trim_start_ms=item.trim_start_ms,
|
||||||
|
trim_end_ms=item.trim_end_ms,
|
||||||
|
created_at=item.created_at,
|
||||||
|
profile_id=generation.profile_id,
|
||||||
|
profile_name=profile.name if profile else "Unknown",
|
||||||
|
text=generation.text,
|
||||||
|
language=generation.language,
|
||||||
|
audio_path=generation.audio_path,
|
||||||
|
duration=generation.duration,
|
||||||
|
seed=generation.seed,
|
||||||
|
instruct=generation.instruct,
|
||||||
|
generation_created_at=generation.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def split_story_item(
|
||||||
|
story_id: str,
|
||||||
|
item_id: str,
|
||||||
|
data: StoryItemSplit,
|
||||||
|
db: Session,
|
||||||
|
) -> Optional[List[StoryItemDetail]]:
|
||||||
|
"""
|
||||||
|
Split a story item at a given time, creating two clips.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story_id: Story ID
|
||||||
|
item_id: Story item ID to split
|
||||||
|
data: Split data (split_time_ms - time within clip to split at)
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of two updated item details (original and new) or None if not found/invalid
|
||||||
|
"""
|
||||||
|
# Get the item
|
||||||
|
item = db.query(DBStoryItem).filter_by(
|
||||||
|
id=item_id,
|
||||||
|
story_id=story_id,
|
||||||
|
).first()
|
||||||
|
if not item:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get the generation
|
||||||
|
generation = db.query(DBGeneration).filter_by(id=item.generation_id).first()
|
||||||
|
if not generation:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Calculate effective duration and validate split point
|
||||||
|
current_trim_start = getattr(item, 'trim_start_ms', 0)
|
||||||
|
current_trim_end = getattr(item, 'trim_end_ms', 0)
|
||||||
|
original_duration_ms = int(generation.duration * 1000)
|
||||||
|
effective_duration_ms = original_duration_ms - current_trim_start - current_trim_end
|
||||||
|
|
||||||
|
# Validate split_time_ms is within the effective duration
|
||||||
|
if data.split_time_ms <= 0 or data.split_time_ms >= effective_duration_ms:
|
||||||
|
return None # Invalid split point
|
||||||
|
|
||||||
|
# Calculate the absolute time in the original audio where we're splitting
|
||||||
|
absolute_split_ms = current_trim_start + data.split_time_ms
|
||||||
|
|
||||||
|
# Update original clip: trim from the end
|
||||||
|
item.trim_end_ms = original_duration_ms - absolute_split_ms
|
||||||
|
|
||||||
|
# Create new clip: starts after the split, trimmed from the start
|
||||||
|
new_item = DBStoryItem(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
story_id=story_id,
|
||||||
|
generation_id=item.generation_id, # Same generation, different trim
|
||||||
|
start_time_ms=item.start_time_ms + data.split_time_ms,
|
||||||
|
track=item.track,
|
||||||
|
trim_start_ms=absolute_split_ms,
|
||||||
|
trim_end_ms=current_trim_end,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(new_item)
|
||||||
|
|
||||||
|
# Update story updated_at
|
||||||
|
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||||
|
if story:
|
||||||
|
story.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(item)
|
||||||
|
db.refresh(new_item)
|
||||||
|
|
||||||
|
# Get profile name
|
||||||
|
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||||
|
profile_name = profile.name if profile else "Unknown"
|
||||||
|
|
||||||
|
# Build response items
|
||||||
|
original_item_detail = StoryItemDetail(
|
||||||
|
id=item.id,
|
||||||
|
story_id=item.story_id,
|
||||||
|
generation_id=item.generation_id,
|
||||||
|
start_time_ms=item.start_time_ms,
|
||||||
|
track=item.track,
|
||||||
|
trim_start_ms=item.trim_start_ms,
|
||||||
|
trim_end_ms=item.trim_end_ms,
|
||||||
|
created_at=item.created_at,
|
||||||
|
profile_id=generation.profile_id,
|
||||||
|
profile_name=profile_name,
|
||||||
|
text=generation.text,
|
||||||
|
language=generation.language,
|
||||||
|
audio_path=generation.audio_path,
|
||||||
|
duration=generation.duration,
|
||||||
|
seed=generation.seed,
|
||||||
|
instruct=generation.instruct,
|
||||||
|
generation_created_at=generation.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
new_item_detail = StoryItemDetail(
|
||||||
|
id=new_item.id,
|
||||||
|
story_id=new_item.story_id,
|
||||||
|
generation_id=new_item.generation_id,
|
||||||
|
start_time_ms=new_item.start_time_ms,
|
||||||
|
track=new_item.track,
|
||||||
|
trim_start_ms=new_item.trim_start_ms,
|
||||||
|
trim_end_ms=new_item.trim_end_ms,
|
||||||
|
created_at=new_item.created_at,
|
||||||
|
profile_id=generation.profile_id,
|
||||||
|
profile_name=profile_name,
|
||||||
|
text=generation.text,
|
||||||
|
language=generation.language,
|
||||||
|
audio_path=generation.audio_path,
|
||||||
|
duration=generation.duration,
|
||||||
|
seed=generation.seed,
|
||||||
|
instruct=generation.instruct,
|
||||||
|
generation_created_at=generation.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
return [original_item_detail, new_item_detail]
|
||||||
|
|
||||||
|
|
||||||
|
async def duplicate_story_item(
|
||||||
|
story_id: str,
|
||||||
|
item_id: str,
|
||||||
|
db: Session,
|
||||||
|
) -> Optional[StoryItemDetail]:
|
||||||
|
"""
|
||||||
|
Duplicate a story item, creating a copy with all properties.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story_id: Story ID
|
||||||
|
item_id: Story item ID to duplicate
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New item detail or None if not found
|
||||||
|
"""
|
||||||
|
# Get the original item
|
||||||
|
original_item = db.query(DBStoryItem).filter_by(
|
||||||
|
id=item_id,
|
||||||
|
story_id=story_id,
|
||||||
|
).first()
|
||||||
|
if not original_item:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get the generation
|
||||||
|
generation = db.query(DBGeneration).filter_by(id=original_item.generation_id).first()
|
||||||
|
if not generation:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Calculate effective duration
|
||||||
|
current_trim_start = getattr(original_item, 'trim_start_ms', 0)
|
||||||
|
current_trim_end = getattr(original_item, 'trim_end_ms', 0)
|
||||||
|
original_duration_ms = int(generation.duration * 1000)
|
||||||
|
effective_duration_ms = original_duration_ms - current_trim_start - current_trim_end
|
||||||
|
|
||||||
|
# Create duplicate item - place it right after the original
|
||||||
|
new_item = DBStoryItem(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
story_id=story_id,
|
||||||
|
generation_id=original_item.generation_id, # Same generation as original
|
||||||
|
start_time_ms=original_item.start_time_ms + effective_duration_ms + 200, # 200ms gap
|
||||||
|
track=original_item.track,
|
||||||
|
trim_start_ms=current_trim_start,
|
||||||
|
trim_end_ms=current_trim_end,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(new_item)
|
||||||
|
|
||||||
|
# Update story updated_at
|
||||||
|
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||||
|
if story:
|
||||||
|
story.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_item)
|
||||||
|
|
||||||
|
# Get profile name
|
||||||
|
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||||
|
|
||||||
|
return StoryItemDetail(
|
||||||
|
id=new_item.id,
|
||||||
|
story_id=new_item.story_id,
|
||||||
|
generation_id=new_item.generation_id,
|
||||||
|
start_time_ms=new_item.start_time_ms,
|
||||||
|
track=new_item.track,
|
||||||
|
trim_start_ms=new_item.trim_start_ms,
|
||||||
|
trim_end_ms=new_item.trim_end_ms,
|
||||||
|
created_at=new_item.created_at,
|
||||||
|
profile_id=generation.profile_id,
|
||||||
|
profile_name=profile.name if profile else "Unknown",
|
||||||
|
text=generation.text,
|
||||||
|
language=generation.language,
|
||||||
|
audio_path=generation.audio_path,
|
||||||
|
duration=generation.duration,
|
||||||
|
seed=generation.seed,
|
||||||
|
instruct=generation.instruct,
|
||||||
|
generation_created_at=generation.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_story_item_times(
|
||||||
|
story_id: str,
|
||||||
|
data: StoryItemBatchUpdate,
|
||||||
|
db: Session,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Update story item timecodes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story_id: Story ID
|
||||||
|
data: Batch update data with timecodes
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if updated, False if story not found or invalid
|
||||||
|
"""
|
||||||
|
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||||
|
if not story:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get all items for this story
|
||||||
|
items = db.query(DBStoryItem).filter_by(story_id=story_id).all()
|
||||||
|
item_map = {item.generation_id: item for item in items}
|
||||||
|
|
||||||
|
# Verify all generation IDs belong to this story and update timecodes
|
||||||
|
for update in data.updates:
|
||||||
|
if update.generation_id not in item_map:
|
||||||
|
return False
|
||||||
|
item_map[update.generation_id].start_time_ms = update.start_time_ms
|
||||||
|
|
||||||
|
# Update story updated_at
|
||||||
|
story.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def reorder_story_items(
|
||||||
|
story_id: str,
|
||||||
|
generation_ids: List[str],
|
||||||
|
db: Session,
|
||||||
|
gap_ms: int = 200,
|
||||||
|
) -> Optional[List[StoryItemDetail]]:
|
||||||
|
"""
|
||||||
|
Reorder story items and recalculate timecodes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story_id: Story ID
|
||||||
|
generation_ids: List of generation IDs in the desired order
|
||||||
|
db: Database session
|
||||||
|
gap_ms: Gap in milliseconds between items (default 200ms)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated list of story items with new timecodes, or None if invalid
|
||||||
|
"""
|
||||||
|
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||||
|
if not story:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get all items for this story with their generation data
|
||||||
|
items_with_gen = db.query(
|
||||||
|
DBStoryItem,
|
||||||
|
DBGeneration,
|
||||||
|
DBVoiceProfile.name.label('profile_name')
|
||||||
|
).join(
|
||||||
|
DBGeneration,
|
||||||
|
DBStoryItem.generation_id == DBGeneration.id
|
||||||
|
).join(
|
||||||
|
DBVoiceProfile,
|
||||||
|
DBGeneration.profile_id == DBVoiceProfile.id
|
||||||
|
).filter(
|
||||||
|
DBStoryItem.story_id == story_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Create maps for quick lookup
|
||||||
|
item_map = {item.generation_id: (item, gen, profile_name) for item, gen, profile_name in items_with_gen}
|
||||||
|
|
||||||
|
# Verify all generation IDs belong to this story
|
||||||
|
if set(generation_ids) != set(item_map.keys()):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Recalculate timecodes based on new order
|
||||||
|
current_time_ms = 0
|
||||||
|
updated_items = []
|
||||||
|
|
||||||
|
for gen_id in generation_ids:
|
||||||
|
item, generation, profile_name = item_map[gen_id]
|
||||||
|
|
||||||
|
# Update the item's start time
|
||||||
|
item.start_time_ms = current_time_ms
|
||||||
|
|
||||||
|
# Calculate the duration in ms
|
||||||
|
duration_ms = int(generation.duration * 1000)
|
||||||
|
|
||||||
|
# Move to next position (current end + gap)
|
||||||
|
current_time_ms += duration_ms + gap_ms
|
||||||
|
|
||||||
|
# Build the response item
|
||||||
|
updated_items.append(StoryItemDetail(
|
||||||
|
id=item.id,
|
||||||
|
story_id=item.story_id,
|
||||||
|
generation_id=item.generation_id,
|
||||||
|
start_time_ms=item.start_time_ms,
|
||||||
|
track=item.track,
|
||||||
|
trim_start_ms=getattr(item, 'trim_start_ms', 0),
|
||||||
|
trim_end_ms=getattr(item, 'trim_end_ms', 0),
|
||||||
|
created_at=item.created_at,
|
||||||
|
profile_id=generation.profile_id,
|
||||||
|
profile_name=profile_name,
|
||||||
|
text=generation.text,
|
||||||
|
language=generation.language,
|
||||||
|
audio_path=generation.audio_path,
|
||||||
|
duration=generation.duration,
|
||||||
|
seed=generation.seed,
|
||||||
|
instruct=generation.instruct,
|
||||||
|
generation_created_at=generation.created_at,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Update story updated_at
|
||||||
|
story.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return updated_items
|
||||||
|
|
||||||
|
|
||||||
|
async def export_story_audio(
|
||||||
|
story_id: str,
|
||||||
|
db: Session,
|
||||||
|
) -> Optional[bytes]:
|
||||||
|
"""
|
||||||
|
Export story as single mixed audio file with timecode-based mixing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story_id: Story ID
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Audio file bytes or None if story not found
|
||||||
|
"""
|
||||||
|
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||||
|
if not story:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get all items ordered by start_time_ms
|
||||||
|
items = db.query(
|
||||||
|
DBStoryItem,
|
||||||
|
DBGeneration
|
||||||
|
).join(
|
||||||
|
DBGeneration,
|
||||||
|
DBStoryItem.generation_id == DBGeneration.id
|
||||||
|
).filter(
|
||||||
|
DBStoryItem.story_id == story_id
|
||||||
|
).order_by(DBStoryItem.start_time_ms).all()
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Load all audio files and calculate total duration
|
||||||
|
audio_data = []
|
||||||
|
sample_rate = 24000 # Default sample rate
|
||||||
|
|
||||||
|
for item, generation in items:
|
||||||
|
audio_path = Path(generation.audio_path)
|
||||||
|
if not audio_path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
audio, sr = load_audio(str(audio_path), sample_rate=sample_rate)
|
||||||
|
sample_rate = sr # Use actual sample rate from first file
|
||||||
|
|
||||||
|
# Get trim values
|
||||||
|
trim_start_ms = getattr(item, 'trim_start_ms', 0)
|
||||||
|
trim_end_ms = getattr(item, 'trim_end_ms', 0)
|
||||||
|
|
||||||
|
# Calculate effective duration
|
||||||
|
original_duration_ms = int(generation.duration * 1000)
|
||||||
|
effective_duration_ms = original_duration_ms - trim_start_ms - trim_end_ms
|
||||||
|
|
||||||
|
# Slice audio based on trim values
|
||||||
|
trim_start_sample = int((trim_start_ms / 1000.0) * sample_rate)
|
||||||
|
trim_end_sample = int((trim_end_ms / 1000.0) * sample_rate)
|
||||||
|
|
||||||
|
# Extract the trimmed portion
|
||||||
|
if trim_end_ms > 0:
|
||||||
|
trimmed_audio = audio[trim_start_sample:-trim_end_sample] if trim_end_sample > 0 else audio[trim_start_sample:]
|
||||||
|
else:
|
||||||
|
trimmed_audio = audio[trim_start_sample:]
|
||||||
|
|
||||||
|
# Store audio with its timecode info
|
||||||
|
start_time_ms = item.start_time_ms
|
||||||
|
|
||||||
|
audio_data.append({
|
||||||
|
'audio': trimmed_audio,
|
||||||
|
'start_time_ms': start_time_ms,
|
||||||
|
'duration_ms': effective_duration_ms,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
# Skip files that can't be loaded
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not audio_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Calculate total duration: max(start_time_ms + duration_ms)
|
||||||
|
max_end_time_ms = max(
|
||||||
|
(data['start_time_ms'] + data['duration_ms'] for data in audio_data),
|
||||||
|
default=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert to samples
|
||||||
|
total_samples = int((max_end_time_ms / 1000.0) * sample_rate)
|
||||||
|
|
||||||
|
# Create output buffer initialized to zeros
|
||||||
|
final_audio = np.zeros(total_samples, dtype=np.float32)
|
||||||
|
|
||||||
|
# Mix each audio segment at its timecode position
|
||||||
|
for data in audio_data:
|
||||||
|
audio = data['audio']
|
||||||
|
start_time_ms = data['start_time_ms']
|
||||||
|
|
||||||
|
# Calculate start sample index
|
||||||
|
start_sample = int((start_time_ms / 1000.0) * sample_rate)
|
||||||
|
|
||||||
|
# Ensure we don't exceed buffer bounds
|
||||||
|
audio_length = len(audio)
|
||||||
|
end_sample = min(start_sample + audio_length, total_samples)
|
||||||
|
|
||||||
|
if start_sample < total_samples:
|
||||||
|
# Trim audio if it extends beyond buffer
|
||||||
|
audio_to_mix = audio[:end_sample - start_sample]
|
||||||
|
|
||||||
|
# Mix: add audio to existing buffer (overlapping audio will sum)
|
||||||
|
# Normalize to prevent clipping (simple approach: divide by max)
|
||||||
|
final_audio[start_sample:end_sample] += audio_to_mix
|
||||||
|
|
||||||
|
# Normalize to prevent clipping
|
||||||
|
max_val = np.abs(final_audio).max()
|
||||||
|
if max_val > 1.0:
|
||||||
|
final_audio = final_audio / max_val
|
||||||
|
|
||||||
|
# Save to temporary file
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
save_audio(final_audio, tmp_path, sample_rate)
|
||||||
|
|
||||||
|
# Read file bytes
|
||||||
|
with open(tmp_path, 'rb') as f:
|
||||||
|
audio_bytes = f.read()
|
||||||
|
|
||||||
|
return audio_bytes
|
||||||
|
finally:
|
||||||
|
# Clean up temp file
|
||||||
|
Path(tmp_path).unlink(missing_ok=True)
|
||||||
@@ -162,9 +162,10 @@ class WhisperModel:
|
|||||||
# Set language if provided
|
# Set language if provided
|
||||||
forced_decoder_ids = None
|
forced_decoder_ids = None
|
||||||
if language:
|
if language:
|
||||||
lang_code = "en" if language == "en" else "zh"
|
# Support all languages from frontend: en, zh, ja, ko, de, fr, ru, pt, es, it
|
||||||
|
# Whisper supports these and many more
|
||||||
forced_decoder_ids = self.processor.get_decoder_prompt_ids(
|
forced_decoder_ids = self.processor.get_decoder_prompt_ids(
|
||||||
language=lang_code,
|
language=language,
|
||||||
task="transcribe",
|
task="transcribe",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -221,9 +222,10 @@ class WhisperModel:
|
|||||||
# Set language if provided
|
# Set language if provided
|
||||||
forced_decoder_ids = None
|
forced_decoder_ids = None
|
||||||
if language:
|
if language:
|
||||||
lang_code = "en" if language == "en" else "zh"
|
# Support all languages from frontend: en, zh, ja, ko, de, fr, ru, pt, es, it
|
||||||
|
# Whisper supports these and many more
|
||||||
forced_decoder_ids = self.processor.get_decoder_prompt_ids(
|
forced_decoder_ids = self.processor.get_decoder_prompt_ids(
|
||||||
language=lang_code,
|
language=language,
|
||||||
task="transcribe",
|
task="transcribe",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+46
-14
@@ -26,7 +26,7 @@ class ProgressManager:
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Update progress for a model download.
|
Update progress for a model download.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
model_name: Name of the model (e.g., "qwen-tts-1.7B", "whisper-base")
|
model_name: Name of the model (e.g., "qwen-tts-1.7B", "whisper-base")
|
||||||
current: Current bytes downloaded
|
current: Current bytes downloaded
|
||||||
@@ -34,8 +34,11 @@ class ProgressManager:
|
|||||||
filename: Current file being downloaded
|
filename: Current file being downloaded
|
||||||
status: Status string (downloading, extracting, complete, error)
|
status: Status string (downloading, extracting, complete, error)
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
progress_pct = (current / total * 100) if total > 0 else 0
|
progress_pct = (current / total * 100) if total > 0 else 0
|
||||||
|
|
||||||
self._progress[model_name] = {
|
self._progress[model_name] = {
|
||||||
"model_name": model_name,
|
"model_name": model_name,
|
||||||
"current": current,
|
"current": current,
|
||||||
@@ -45,14 +48,18 @@ class ProgressManager:
|
|||||||
"status": status,
|
"status": status,
|
||||||
"timestamp": datetime.now().isoformat(),
|
"timestamp": datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Notify all listeners
|
# Notify all listeners
|
||||||
if model_name in self._listeners:
|
listener_count = len(self._listeners.get(model_name, []))
|
||||||
|
if listener_count > 0:
|
||||||
|
logger.debug(f"Notifying {listener_count} listeners for {model_name}: {progress_pct:.1f}% ({filename})")
|
||||||
for queue in self._listeners[model_name]:
|
for queue in self._listeners[model_name]:
|
||||||
try:
|
try:
|
||||||
queue.put_nowait(self._progress[model_name].copy())
|
queue.put_nowait(self._progress[model_name].copy())
|
||||||
except asyncio.QueueFull:
|
except asyncio.QueueFull:
|
||||||
pass
|
logger.warning(f"Queue full for {model_name}, dropping update")
|
||||||
|
else:
|
||||||
|
logger.debug(f"No listeners for {model_name}, progress update stored: {progress_pct:.1f}%")
|
||||||
|
|
||||||
def get_progress(self, model_name: str) -> Optional[Dict]:
|
def get_progress(self, model_name: str) -> Optional[Dict]:
|
||||||
"""Get current progress for a model."""
|
"""Get current progress for a model."""
|
||||||
@@ -98,30 +105,46 @@ class ProgressManager:
|
|||||||
async def subscribe(self, model_name: str):
|
async def subscribe(self, model_name: str):
|
||||||
"""
|
"""
|
||||||
Subscribe to progress updates for a model.
|
Subscribe to progress updates for a model.
|
||||||
|
|
||||||
Yields progress updates as Server-Sent Events.
|
Yields progress updates as Server-Sent Events.
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
queue = asyncio.Queue(maxsize=10)
|
queue = asyncio.Queue(maxsize=10)
|
||||||
|
|
||||||
# Add to listeners
|
# Add to listeners
|
||||||
if model_name not in self._listeners:
|
if model_name not in self._listeners:
|
||||||
self._listeners[model_name] = []
|
self._listeners[model_name] = []
|
||||||
self._listeners[model_name].append(queue)
|
self._listeners[model_name].append(queue)
|
||||||
|
|
||||||
|
logger.info(f"SSE client subscribed to {model_name}, total listeners: {len(self._listeners[model_name])}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Send initial progress if available
|
# Send initial progress if available and still in progress
|
||||||
if model_name in self._progress:
|
if model_name in self._progress:
|
||||||
yield f"data: {json.dumps(self._progress[model_name])}\n\n"
|
status = self._progress[model_name].get('status')
|
||||||
|
# Only send initial progress if download is actually in progress
|
||||||
|
# Don't send old 'complete' or 'error' status from previous downloads
|
||||||
|
if status in ('downloading', 'extracting'):
|
||||||
|
logger.info(f"Sending initial progress for {model_name}: {status}")
|
||||||
|
yield f"data: {json.dumps(self._progress[model_name])}\n\n"
|
||||||
|
else:
|
||||||
|
logger.info(f"Skipping initial progress for {model_name} (status: {status})")
|
||||||
|
else:
|
||||||
|
logger.info(f"No initial progress available for {model_name}")
|
||||||
|
|
||||||
# Stream updates
|
# Stream updates
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# Wait for update with timeout
|
# Wait for update with timeout
|
||||||
progress = await asyncio.wait_for(queue.get(), timeout=1.0)
|
progress = await asyncio.wait_for(queue.get(), timeout=1.0)
|
||||||
|
logger.debug(f"Sending progress update for {model_name}: {progress.get('status')} - {progress.get('progress', 0):.1f}%")
|
||||||
yield f"data: {json.dumps(progress)}\n\n"
|
yield f"data: {json.dumps(progress)}\n\n"
|
||||||
|
|
||||||
# Stop if complete or error
|
# Stop if complete or error
|
||||||
if progress.get("status") in ("complete", "error"):
|
if progress.get("status") in ("complete", "error"):
|
||||||
|
logger.info(f"Download {progress.get('status')} for {model_name}, closing SSE connection")
|
||||||
break
|
break
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
# Send heartbeat
|
# Send heartbeat
|
||||||
@@ -133,32 +156,41 @@ class ProgressManager:
|
|||||||
self._listeners[model_name].remove(queue)
|
self._listeners[model_name].remove(queue)
|
||||||
if not self._listeners[model_name]:
|
if not self._listeners[model_name]:
|
||||||
del self._listeners[model_name]
|
del self._listeners[model_name]
|
||||||
|
logger.info(f"SSE client unsubscribed from {model_name}, remaining listeners: {len(self._listeners.get(model_name, []))}")
|
||||||
|
|
||||||
def mark_complete(self, model_name: str):
|
def mark_complete(self, model_name: str):
|
||||||
"""Mark a model download as complete."""
|
"""Mark a model download as complete."""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
if model_name in self._progress:
|
if model_name in self._progress:
|
||||||
self._progress[model_name]["status"] = "complete"
|
self._progress[model_name]["status"] = "complete"
|
||||||
self._progress[model_name]["progress"] = 100.0
|
self._progress[model_name]["progress"] = 100.0
|
||||||
|
logger.info(f"Marked {model_name} as complete")
|
||||||
# Notify listeners
|
# Notify listeners
|
||||||
if model_name in self._listeners:
|
if model_name in self._listeners:
|
||||||
for queue in self._listeners[model_name]:
|
for queue in self._listeners[model_name]:
|
||||||
try:
|
try:
|
||||||
queue.put_nowait(self._progress[model_name].copy())
|
queue.put_nowait(self._progress[model_name].copy())
|
||||||
except asyncio.QueueFull:
|
except asyncio.QueueFull:
|
||||||
pass
|
logger.warning(f"Queue full when marking {model_name} complete")
|
||||||
|
|
||||||
def mark_error(self, model_name: str, error: str):
|
def mark_error(self, model_name: str, error: str):
|
||||||
"""Mark a model download as failed."""
|
"""Mark a model download as failed."""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
if model_name in self._progress:
|
if model_name in self._progress:
|
||||||
self._progress[model_name]["status"] = "error"
|
self._progress[model_name]["status"] = "error"
|
||||||
self._progress[model_name]["error"] = error
|
self._progress[model_name]["error"] = error
|
||||||
|
logger.error(f"Marked {model_name} as error: {error}")
|
||||||
# Notify listeners
|
# Notify listeners
|
||||||
if model_name in self._listeners:
|
if model_name in self._listeners:
|
||||||
for queue in self._listeners[model_name]:
|
for queue in self._listeners[model_name]:
|
||||||
try:
|
try:
|
||||||
queue.put_nowait(self._progress[model_name].copy())
|
queue.put_nowait(self._progress[model_name].copy())
|
||||||
except asyncio.QueueFull:
|
except asyncio.QueueFull:
|
||||||
pass
|
logger.warning(f"Queue full when marking {model_name} error")
|
||||||
|
|
||||||
|
|
||||||
# Global progress manager instance
|
# Global progress manager instance
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
|
||||||
from PyInstaller.utils.hooks import collect_data_files
|
|
||||||
from PyInstaller.utils.hooks import collect_submodules
|
|
||||||
from PyInstaller.utils.hooks import copy_metadata
|
|
||||||
|
|
||||||
datas = []
|
|
||||||
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.utils.validation', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'pkg_resources.extern']
|
|
||||||
datas += collect_data_files('qwen_tts')
|
|
||||||
datas += copy_metadata('qwen-tts')
|
|
||||||
hiddenimports += collect_submodules('qwen_tts')
|
|
||||||
hiddenimports += collect_submodules('jaraco')
|
|
||||||
|
|
||||||
|
|
||||||
a = Analysis(
|
|
||||||
['server.py'],
|
|
||||||
pathex=['C:\\Users\\ijame\\Projects\\voice\\Qwen3-TTS'],
|
|
||||||
binaries=[],
|
|
||||||
datas=datas,
|
|
||||||
hiddenimports=hiddenimports,
|
|
||||||
hookspath=[],
|
|
||||||
hooksconfig={},
|
|
||||||
runtime_hooks=[],
|
|
||||||
excludes=[],
|
|
||||||
noarchive=False,
|
|
||||||
optimize=0,
|
|
||||||
)
|
|
||||||
pyz = PYZ(a.pure)
|
|
||||||
|
|
||||||
exe = EXE(
|
|
||||||
pyz,
|
|
||||||
a.scripts,
|
|
||||||
a.binaries,
|
|
||||||
a.datas,
|
|
||||||
[],
|
|
||||||
name='voicebox-server',
|
|
||||||
debug=False,
|
|
||||||
bootloader_ignore_signals=False,
|
|
||||||
strip=False,
|
|
||||||
upx=True,
|
|
||||||
upx_exclude=[],
|
|
||||||
runtime_tmpdir=None,
|
|
||||||
console=True,
|
|
||||||
disable_windowed_traceback=False,
|
|
||||||
argv_emulation=False,
|
|
||||||
target_arch=None,
|
|
||||||
codesign_identity=None,
|
|
||||||
entitlements_file=None,
|
|
||||||
)
|
|
||||||
@@ -13,8 +13,11 @@
|
|||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"name": "@voicebox/app",
|
"name": "@voicebox/app",
|
||||||
"version": "0.1.0",
|
"version": "0.1.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-avatar": "^1.1.0",
|
"@radix-ui/react-avatar": "^1.1.0",
|
||||||
@@ -32,6 +35,7 @@
|
|||||||
"@radix-ui/react-toast": "^1.2.1",
|
"@radix-ui/react-toast": "^1.2.1",
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"@tanstack/react-query-devtools": "^5.0.0",
|
"@tanstack/react-query-devtools": "^5.0.0",
|
||||||
|
"@tanstack/react-router": "^1.157.16",
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||||
"@tauri-apps/plugin-fs": "^2.0.0",
|
"@tauri-apps/plugin-fs": "^2.0.0",
|
||||||
@@ -63,7 +67,7 @@
|
|||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"name": "@voicebox/landing",
|
"name": "@voicebox/landing",
|
||||||
"version": "0.1.0",
|
"version": "0.1.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
@@ -88,7 +92,7 @@
|
|||||||
},
|
},
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"name": "@voicebox/tauri",
|
"name": "@voicebox/tauri",
|
||||||
"version": "0.1.0",
|
"version": "0.1.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||||
@@ -107,7 +111,7 @@
|
|||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"name": "@voicebox/web",
|
"name": "@voicebox/web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
@@ -188,6 +192,14 @@
|
|||||||
|
|
||||||
"@biomejs/cli-win32-x64": ["@biomejs/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-qqGVWqNNek0KikwPZlOIoxtXgsNGsX+rgdEzgw82Re8nF02W+E2WokaQhpF5TdBh/D/RQ3TLppH+otp6ztN0lw=="],
|
"@biomejs/cli-win32-x64": ["@biomejs/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-qqGVWqNNek0KikwPZlOIoxtXgsNGsX+rgdEzgw82Re8nF02W+E2WokaQhpF5TdBh/D/RQ3TLppH+otp6ztN0lw=="],
|
||||||
|
|
||||||
|
"@dnd-kit/accessibility": ["@dnd-kit/[email protected]", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
|
||||||
|
|
||||||
|
"@dnd-kit/core": ["@dnd-kit/[email protected]", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
|
||||||
|
|
||||||
|
"@dnd-kit/sortable": ["@dnd-kit/[email protected]", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="],
|
||||||
|
|
||||||
|
"@dnd-kit/utilities": ["@dnd-kit/[email protected]", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
|
||||||
|
|
||||||
"@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
"@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||||
|
|
||||||
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||||
@@ -512,6 +524,8 @@
|
|||||||
|
|
||||||
"@tailwindcss/vite": ["@tailwindcss/[email protected]", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
|
"@tailwindcss/vite": ["@tailwindcss/[email protected]", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
|
||||||
|
|
||||||
|
"@tanstack/history": ["@tanstack/[email protected]", "", {}, "sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA=="],
|
||||||
|
|
||||||
"@tanstack/query-core": ["@tanstack/[email protected]", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
|
"@tanstack/query-core": ["@tanstack/[email protected]", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
|
||||||
|
|
||||||
"@tanstack/query-devtools": ["@tanstack/[email protected]", "", {}, "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ=="],
|
"@tanstack/query-devtools": ["@tanstack/[email protected]", "", {}, "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ=="],
|
||||||
@@ -520,6 +534,14 @@
|
|||||||
|
|
||||||
"@tanstack/react-query-devtools": ["@tanstack/[email protected]", "", { "dependencies": { "@tanstack/query-devtools": "5.92.0" }, "peerDependencies": { "@tanstack/react-query": "^5.90.14", "react": "^18 || ^19" } }, "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg=="],
|
"@tanstack/react-query-devtools": ["@tanstack/[email protected]", "", { "dependencies": { "@tanstack/query-devtools": "5.92.0" }, "peerDependencies": { "@tanstack/react-query": "^5.90.14", "react": "^18 || ^19" } }, "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg=="],
|
||||||
|
|
||||||
|
"@tanstack/react-router": ["@tanstack/[email protected]", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.157.16", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-xwFQa7S7dhBhm3aJYwU79cITEYgAKSrcL6wokaROIvl2JyIeazn8jueWqUPJzFjv+QF6Q8euKRlKUEyb5q2ymg=="],
|
||||||
|
|
||||||
|
"@tanstack/react-store": ["@tanstack/[email protected]", "", { "dependencies": { "@tanstack/store": "0.8.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow=="],
|
||||||
|
|
||||||
|
"@tanstack/router-core": ["@tanstack/[email protected]", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-eJuVgM7KZYTTr4uPorbUzUflmljMVcaX2g6VvhITLnHmg9SBx9RAgtQ1HmT+72mzyIbRSlQ1q0fY/m+of/fosA=="],
|
||||||
|
|
||||||
|
"@tanstack/store": ["@tanstack/[email protected]", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="],
|
||||||
|
|
||||||
"@tauri-apps/api": ["@tauri-apps/[email protected]", "", {}, "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw=="],
|
"@tauri-apps/api": ["@tauri-apps/[email protected]", "", {}, "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw=="],
|
||||||
|
|
||||||
"@tauri-apps/cli": ["@tauri-apps/[email protected]", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.9.6", "@tauri-apps/cli-darwin-x64": "2.9.6", "@tauri-apps/cli-linux-arm-gnueabihf": "2.9.6", "@tauri-apps/cli-linux-arm64-gnu": "2.9.6", "@tauri-apps/cli-linux-arm64-musl": "2.9.6", "@tauri-apps/cli-linux-riscv64-gnu": "2.9.6", "@tauri-apps/cli-linux-x64-gnu": "2.9.6", "@tauri-apps/cli-linux-x64-musl": "2.9.6", "@tauri-apps/cli-win32-arm64-msvc": "2.9.6", "@tauri-apps/cli-win32-ia32-msvc": "2.9.6", "@tauri-apps/cli-win32-x64-msvc": "2.9.6" }, "bin": { "tauri": "tauri.js" } }, "sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw=="],
|
"@tauri-apps/cli": ["@tauri-apps/[email protected]", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.9.6", "@tauri-apps/cli-darwin-x64": "2.9.6", "@tauri-apps/cli-linux-arm-gnueabihf": "2.9.6", "@tauri-apps/cli-linux-arm64-gnu": "2.9.6", "@tauri-apps/cli-linux-arm64-musl": "2.9.6", "@tauri-apps/cli-linux-riscv64-gnu": "2.9.6", "@tauri-apps/cli-linux-x64-gnu": "2.9.6", "@tauri-apps/cli-linux-x64-musl": "2.9.6", "@tauri-apps/cli-win32-arm64-msvc": "2.9.6", "@tauri-apps/cli-win32-ia32-msvc": "2.9.6", "@tauri-apps/cli-win32-x64-msvc": "2.9.6" }, "bin": { "tauri": "tauri.js" } }, "sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw=="],
|
||||||
@@ -664,6 +686,8 @@
|
|||||||
|
|
||||||
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||||
|
|
||||||
|
"cookie-es": ["[email protected]", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
|
||||||
|
|
||||||
"cross-spawn": ["[email protected]", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
"cross-spawn": ["[email protected]", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
"cssesc": ["[email protected]", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
"cssesc": ["[email protected]", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||||
@@ -792,6 +816,8 @@
|
|||||||
|
|
||||||
"is-path-inside": ["[email protected]", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="],
|
"is-path-inside": ["[email protected]", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="],
|
||||||
|
|
||||||
|
"isbot": ["[email protected]", "", {}, "sha512-aCMIBSKd/XPRYdiCQTLC8QHH4YT8B3JUADu+7COgYIZPvkeoMcUHMRjZLM9/7V8fCj+l7FSREc1lOPNjzogo/A=="],
|
||||||
|
|
||||||
"isexe": ["[email protected]", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
"isexe": ["[email protected]", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
"jiti": ["[email protected]", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
|
"jiti": ["[email protected]", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
|
||||||
@@ -966,6 +992,10 @@
|
|||||||
|
|
||||||
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
|
"seroval": ["[email protected]", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="],
|
||||||
|
|
||||||
|
"seroval-plugins": ["[email protected]", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="],
|
||||||
|
|
||||||
"sharp": ["[email protected]", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
"sharp": ["[email protected]", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||||
|
|
||||||
"shebang-command": ["[email protected]", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
"shebang-command": ["[email protected]", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||||
@@ -1002,6 +1032,10 @@
|
|||||||
|
|
||||||
"thenify-all": ["[email protected]", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
"thenify-all": ["[email protected]", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||||
|
|
||||||
|
"tiny-invariant": ["[email protected]", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||||
|
|
||||||
|
"tiny-warning": ["[email protected]", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="],
|
||||||
|
|
||||||
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||||
|
|
||||||
"to-regex-range": ["[email protected]", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
"to-regex-range": ["[email protected]", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||||
|
|||||||
@@ -0,0 +1,758 @@
|
|||||||
|
# Docker Deployment Guide
|
||||||
|
|
||||||
|
**Status:** In Development for v0.2.0
|
||||||
|
**Requested By:** Reddit community ([thread](https://reddit.com/r/LocalLLaMA/...))
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Docker support makes Voicebox easier to deploy, especially for:
|
||||||
|
|
||||||
|
- **Consistent Environments**: Same setup across dev/staging/prod
|
||||||
|
- **GPU Passthrough**: Easy NVIDIA/AMD GPU access
|
||||||
|
- **Server Deployments**: Run on headless Linux servers
|
||||||
|
- **Multi-User Setups**: Isolate instances per user/team
|
||||||
|
- **Cloud Platforms**: Deploy to AWS, GCP, Azure, DigitalOcean
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Using Pre-Built Images (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CPU-only version
|
||||||
|
docker run -p 8000:8000 -v voicebox-data:/app/data \
|
||||||
|
ghcr.io/jamiepine/voicebox:latest
|
||||||
|
|
||||||
|
# NVIDIA GPU version
|
||||||
|
docker run --gpus all -p 8000:8000 -v voicebox-data:/app/data \
|
||||||
|
ghcr.io/jamiepine/voicebox:latest-cuda
|
||||||
|
|
||||||
|
# AMD GPU version (experimental)
|
||||||
|
docker run --device=/dev/kfd --device=/dev/dri -p 8000:8000 \
|
||||||
|
-v voicebox-data:/app/data \
|
||||||
|
ghcr.io/jamiepine/voicebox:latest-rocm
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open: `http://localhost:8000`
|
||||||
|
|
||||||
|
### Using Docker Compose (Easiest)
|
||||||
|
|
||||||
|
Create `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
voicebox:
|
||||||
|
image: ghcr.io/jamiepine/voicebox:latest-cuda
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- voicebox-data:/app/data
|
||||||
|
- huggingface-cache:/root/.cache/huggingface
|
||||||
|
environment:
|
||||||
|
- GPU_MEMORY_FRACTION=0.8 # Use 80% of GPU memory
|
||||||
|
- TTS_MODE=local
|
||||||
|
- WHISPER_MODE=local
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [gpu]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
voicebox-data:
|
||||||
|
huggingface-cache:
|
||||||
|
```
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building From Source
|
||||||
|
|
||||||
|
### Basic Dockerfile
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Dockerfile
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
git \
|
||||||
|
build-essential \
|
||||||
|
ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy application
|
||||||
|
COPY backend/ /app/backend/
|
||||||
|
COPY requirements.txt /app/
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
RUN pip install --no-cache-dir git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||||
|
|
||||||
|
# Create data directory
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Run server
|
||||||
|
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Build and run:
|
||||||
|
```bash
|
||||||
|
docker build -t voicebox .
|
||||||
|
docker run -p 8000:8000 -v $(pwd)/data:/app/data voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Stage Build (Optimized)
|
||||||
|
|
||||||
|
Smaller image size by separating build and runtime:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Dockerfile.optimized
|
||||||
|
# Stage 1: Build dependencies
|
||||||
|
FROM python:3.11-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
git build-essential && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY backend/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir --target=/build/packages \
|
||||||
|
-r requirements.txt
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir --target=/build/packages \
|
||||||
|
git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||||
|
|
||||||
|
# Stage 2: Runtime
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install only runtime dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy installed packages from builder
|
||||||
|
COPY --from=builder /build/packages /usr/local/lib/python3.11/site-packages/
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY backend/ /app/backend/
|
||||||
|
|
||||||
|
# Create data directory
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Build:
|
||||||
|
```bash
|
||||||
|
docker build -f Dockerfile.optimized -t voicebox:slim .
|
||||||
|
```
|
||||||
|
|
||||||
|
## GPU Support
|
||||||
|
|
||||||
|
### NVIDIA GPUs (CUDA)
|
||||||
|
|
||||||
|
**Dockerfile:**
|
||||||
|
```dockerfile
|
||||||
|
FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04
|
||||||
|
|
||||||
|
# Install Python
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
python3.11 python3-pip git ffmpeg && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install PyTorch with CUDA support
|
||||||
|
COPY backend/requirements.txt .
|
||||||
|
RUN pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
|
||||||
|
|
||||||
|
# Install other dependencies
|
||||||
|
RUN pip3 install -r requirements.txt
|
||||||
|
RUN pip3 install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||||
|
|
||||||
|
COPY backend/ /app/backend/
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run with GPU:**
|
||||||
|
```bash
|
||||||
|
docker run --gpus all -p 8000:8000 \
|
||||||
|
-v voicebox-data:/app/data \
|
||||||
|
voicebox:cuda
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker Compose with GPU:**
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
voicebox:
|
||||||
|
image: voicebox:cuda
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: all
|
||||||
|
capabilities: [gpu]
|
||||||
|
```
|
||||||
|
|
||||||
|
### AMD GPUs (ROCm) - Experimental
|
||||||
|
|
||||||
|
**Dockerfile:**
|
||||||
|
```dockerfile
|
||||||
|
FROM rocm/dev-ubuntu-22.04:6.0
|
||||||
|
|
||||||
|
# Install Python
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
python3.11 python3-pip git ffmpeg && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install PyTorch with ROCm support
|
||||||
|
COPY backend/requirements.txt .
|
||||||
|
RUN pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.0
|
||||||
|
|
||||||
|
# Install other dependencies
|
||||||
|
RUN pip3 install -r requirements.txt
|
||||||
|
RUN pip3 install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||||
|
|
||||||
|
# Set ROCm environment variables
|
||||||
|
ENV HSA_OVERRIDE_GFX_VERSION=10.3.0
|
||||||
|
ENV ROCM_PATH=/opt/rocm
|
||||||
|
|
||||||
|
COPY backend/ /app/backend/
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run with AMD GPU:**
|
||||||
|
```bash
|
||||||
|
docker run --device=/dev/kfd --device=/dev/dri \
|
||||||
|
--group-add video --ipc=host --cap-add=SYS_PTRACE \
|
||||||
|
--security-opt seccomp=unconfined \
|
||||||
|
-p 8000:8000 -v voicebox-data:/app/data \
|
||||||
|
voicebox:rocm
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** ROCm support varies by GPU model. Works best on Linux. See [AMD ROCm docs](https://rocm.docs.amd.com) for compatibility.
|
||||||
|
|
||||||
|
## Volume Mounts
|
||||||
|
|
||||||
|
### Essential Volumes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -v voicebox-data:/app/data \ # Profiles, generations, history
|
||||||
|
-v huggingface-cache:/root/.cache/huggingface \ # Downloaded models
|
||||||
|
-p 8000:8000 voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Volume Mounts
|
||||||
|
|
||||||
|
For development with hot-reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -v $(pwd)/backend:/app/backend \ # Live code changes
|
||||||
|
-v voicebox-data:/app/data \
|
||||||
|
-e RELOAD=true \
|
||||||
|
-p 8000:8000 voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Model Storage
|
||||||
|
|
||||||
|
Use external model directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -v /path/to/models:/models \
|
||||||
|
-e MODELS_DIR=/models \
|
||||||
|
-v voicebox-data:/app/data \
|
||||||
|
-p 8000:8000 voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Configure Voicebox via environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -e TTS_MODE=local \
|
||||||
|
-e WHISPER_MODE=openai-api \
|
||||||
|
-e OPENAI_API_KEY=sk-... \
|
||||||
|
-e GPU_MEMORY_FRACTION=0.8 \
|
||||||
|
-e LOG_LEVEL=info \
|
||||||
|
-p 8000:8000 voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `TTS_MODE` | `local` | TTS provider: `local`, `remote` |
|
||||||
|
| `TTS_REMOTE_URL` | - | URL for remote TTS server |
|
||||||
|
| `WHISPER_MODE` | `local` | Whisper provider: `local`, `openai-api`, `remote` |
|
||||||
|
| `WHISPER_REMOTE_URL` | - | URL for remote Whisper server |
|
||||||
|
| `OPENAI_API_KEY` | - | OpenAI API key (if using OpenAI Whisper) |
|
||||||
|
| `GPU_MEMORY_FRACTION` | `0.9` | Fraction of GPU memory to use (0.0-1.0) |
|
||||||
|
| `DATA_DIR` | `/app/data` | Directory for profiles/generations |
|
||||||
|
| `MODELS_DIR` | `/app/models` | Directory for local models |
|
||||||
|
| `LOG_LEVEL` | `info` | Logging level: `debug`, `info`, `warning`, `error` |
|
||||||
|
| `RELOAD` | `false` | Enable hot-reload for development |
|
||||||
|
|
||||||
|
## Complete Docker Compose Examples
|
||||||
|
|
||||||
|
### Production Deployment
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.prod.yml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
voicebox:
|
||||||
|
image: ghcr.io/jamiepine/voicebox:latest-cuda
|
||||||
|
container_name: voicebox
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- voicebox-data:/app/data
|
||||||
|
- huggingface-cache:/root/.cache/huggingface
|
||||||
|
environment:
|
||||||
|
- TTS_MODE=local
|
||||||
|
- WHISPER_MODE=local
|
||||||
|
- GPU_MEMORY_FRACTION=0.8
|
||||||
|
- LOG_LEVEL=info
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [gpu]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
voicebox-data:
|
||||||
|
driver: local
|
||||||
|
huggingface-cache:
|
||||||
|
driver: local
|
||||||
|
```
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.dev.yml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
voicebox:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app/backend:ro
|
||||||
|
- voicebox-data:/app/data
|
||||||
|
- huggingface-cache:/root/.cache/huggingface
|
||||||
|
environment:
|
||||||
|
- RELOAD=true
|
||||||
|
- LOG_LEVEL=debug
|
||||||
|
- TTS_MODE=local
|
||||||
|
command: uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
voicebox-data:
|
||||||
|
huggingface-cache:
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Service Stack
|
||||||
|
|
||||||
|
Full stack with reverse proxy and monitoring:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.stack.yml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Main Voicebox app
|
||||||
|
voicebox:
|
||||||
|
image: ghcr.io/jamiepine/voicebox:latest-cuda
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- voicebox-data:/app/data
|
||||||
|
- huggingface-cache:/root/.cache/huggingface
|
||||||
|
environment:
|
||||||
|
- TTS_MODE=local
|
||||||
|
- WHISPER_MODE=local
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [gpu]
|
||||||
|
|
||||||
|
# Nginx reverse proxy
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./ssl:/etc/nginx/ssl:ro
|
||||||
|
depends_on:
|
||||||
|
- voicebox
|
||||||
|
|
||||||
|
# Prometheus monitoring (optional)
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus
|
||||||
|
ports:
|
||||||
|
- "9090:9090"
|
||||||
|
volumes:
|
||||||
|
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
|
- prometheus-data:/prometheus
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
voicebox-data:
|
||||||
|
huggingface-cache:
|
||||||
|
prometheus-data:
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cloud Deployment
|
||||||
|
|
||||||
|
### AWS EC2
|
||||||
|
|
||||||
|
1. **Launch GPU Instance** (g4dn.xlarge or p3.2xlarge)
|
||||||
|
2. **Install Docker + nvidia-docker:**
|
||||||
|
```bash
|
||||||
|
# Amazon Linux 2
|
||||||
|
sudo yum install -y docker
|
||||||
|
sudo systemctl start docker
|
||||||
|
distribution=$(. /etc/os-release;echo $ID$VERSION_ID)
|
||||||
|
curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | sudo apt-key add -
|
||||||
|
curl -s -L https://nvidia.github.io/nvidia-docker/$distribution/nvidia-docker.list | \
|
||||||
|
sudo tee /etc/apt/sources.list.d/nvidia-docker.list
|
||||||
|
sudo apt-get update && sudo apt-get install -y nvidia-docker2
|
||||||
|
sudo systemctl restart docker
|
||||||
|
```
|
||||||
|
3. **Deploy:**
|
||||||
|
```bash
|
||||||
|
docker run --gpus all -d -p 80:8000 \
|
||||||
|
-v voicebox-data:/app/data \
|
||||||
|
--restart unless-stopped \
|
||||||
|
ghcr.io/jamiepine/voicebox:latest-cuda
|
||||||
|
```
|
||||||
|
|
||||||
|
### DigitalOcean
|
||||||
|
|
||||||
|
Use GPU Droplet + Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create droplet via CLI
|
||||||
|
doctl compute droplet create voicebox \
|
||||||
|
--size gpu-h100x1-80gb \
|
||||||
|
--image ubuntu-22-04-x64 \
|
||||||
|
--region nyc3
|
||||||
|
|
||||||
|
# SSH and deploy
|
||||||
|
ssh root@<droplet-ip>
|
||||||
|
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||||
|
sh get-docker.sh
|
||||||
|
docker run --gpus all -d -p 80:8000 voicebox:cuda
|
||||||
|
```
|
||||||
|
|
||||||
|
### Google Cloud Run (CPU-only)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and push
|
||||||
|
docker build -t gcr.io/your-project/voicebox .
|
||||||
|
docker push gcr.io/your-project/voicebox
|
||||||
|
|
||||||
|
# Deploy to Cloud Run
|
||||||
|
gcloud run deploy voicebox \
|
||||||
|
--image gcr.io/your-project/voicebox \
|
||||||
|
--platform managed \
|
||||||
|
--region us-central1 \
|
||||||
|
--memory 4Gi \
|
||||||
|
--cpu 2 \
|
||||||
|
--port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fly.io
|
||||||
|
|
||||||
|
Create `fly.toml`:
|
||||||
|
```toml
|
||||||
|
app = "voicebox"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
image = "ghcr.io/jamiepine/voicebox:latest"
|
||||||
|
|
||||||
|
[[services]]
|
||||||
|
http_checks = []
|
||||||
|
internal_port = 8000
|
||||||
|
protocol = "tcp"
|
||||||
|
|
||||||
|
[[services.ports]]
|
||||||
|
port = 80
|
||||||
|
handlers = ["http"]
|
||||||
|
|
||||||
|
[[services.ports]]
|
||||||
|
port = 443
|
||||||
|
handlers = ["tls", "http"]
|
||||||
|
|
||||||
|
[mounts]
|
||||||
|
source = "voicebox_data"
|
||||||
|
destination = "/app/data"
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy:
|
||||||
|
```bash
|
||||||
|
fly launch
|
||||||
|
fly deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### GPU Not Detected
|
||||||
|
|
||||||
|
**Check NVIDIA Docker:**
|
||||||
|
```bash
|
||||||
|
docker run --rm --gpus all nvidia/cuda:12.1.0-base-ubuntu22.04 nvidia-smi
|
||||||
|
```
|
||||||
|
|
||||||
|
If this fails, reinstall nvidia-docker2.
|
||||||
|
|
||||||
|
**Check AMD ROCm:**
|
||||||
|
```bash
|
||||||
|
docker run --rm --device=/dev/kfd --device=/dev/dri rocm/dev-ubuntu-22.04:6.0 rocminfo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission Errors
|
||||||
|
|
||||||
|
Container can't write to volumes:
|
||||||
|
```bash
|
||||||
|
# Fix permissions
|
||||||
|
docker run --user $(id -u):$(id -g) -v $(pwd)/data:/app/data voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
### Out of Memory
|
||||||
|
|
||||||
|
Reduce GPU memory usage:
|
||||||
|
```bash
|
||||||
|
docker run -e GPU_MEMORY_FRACTION=0.5 voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use CPU-only:
|
||||||
|
```bash
|
||||||
|
docker run -e DEVICE=cpu voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model Download Fails
|
||||||
|
|
||||||
|
Ensure HuggingFace cache is writable:
|
||||||
|
```bash
|
||||||
|
docker run -v huggingface-cache:/root/.cache/huggingface voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use host cache:
|
||||||
|
```bash
|
||||||
|
docker run -v ~/.cache/huggingface:/root/.cache/huggingface voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port Already in Use
|
||||||
|
|
||||||
|
Change host port:
|
||||||
|
```bash
|
||||||
|
docker run -p 8080:8000 voicebox # Use port 8080 instead
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
### 1. Don't Run as Root
|
||||||
|
|
||||||
|
Create non-root user in Dockerfile:
|
||||||
|
```dockerfile
|
||||||
|
RUN useradd -m -u 1000 voicebox
|
||||||
|
USER voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Use Secrets for API Keys
|
||||||
|
|
||||||
|
Don't put API keys in docker-compose.yml:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use Docker secrets
|
||||||
|
echo "sk-your-key" | docker secret create openai_key -
|
||||||
|
|
||||||
|
docker service create \
|
||||||
|
--secret openai_key \
|
||||||
|
-e OPENAI_API_KEY_FILE=/run/secrets/openai_key \
|
||||||
|
voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Network Isolation
|
||||||
|
|
||||||
|
Use internal networks for multi-container setups:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
voicebox:
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
nginx:
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
- external
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
|
internal: true
|
||||||
|
external:
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Resource Limits
|
||||||
|
|
||||||
|
Prevent resource exhaustion:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
voicebox:
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '4'
|
||||||
|
memory: 8G
|
||||||
|
reservations:
|
||||||
|
cpus: '2'
|
||||||
|
memory: 4G
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Tuning
|
||||||
|
|
||||||
|
### GPU Memory Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use 80% of GPU (default 90%)
|
||||||
|
docker run -e GPU_MEMORY_FRACTION=0.8 voicebox
|
||||||
|
|
||||||
|
# Allow GPU memory growth (prevents OOM)
|
||||||
|
docker run -e TF_FORCE_GPU_ALLOW_GROWTH=true voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model Caching
|
||||||
|
|
||||||
|
Pre-download models to volume:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download models first
|
||||||
|
docker run --rm -v huggingface-cache:/root/.cache/huggingface \
|
||||||
|
voicebox python -c "
|
||||||
|
from transformers import WhisperProcessor, WhisperForConditionalGeneration
|
||||||
|
WhisperProcessor.from_pretrained('openai/whisper-base')
|
||||||
|
WhisperForConditionalGeneration.from_pretrained('openai/whisper-base')
|
||||||
|
"
|
||||||
|
|
||||||
|
# Then run normally
|
||||||
|
docker run -v huggingface-cache:/root/.cache/huggingface voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Worker Setup
|
||||||
|
|
||||||
|
Use uvicorn workers for better throughput:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
Built-in health endpoint:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker health check:
|
||||||
|
```yaml
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prometheus Metrics
|
||||||
|
|
||||||
|
Add metrics exporter:
|
||||||
|
```python
|
||||||
|
# backend/main.py
|
||||||
|
from prometheus_fastapi_instrumentator import Instrumentator
|
||||||
|
|
||||||
|
Instrumentator().instrument(app).expose(app)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then scrape `/metrics` with Prometheus.
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
View container logs:
|
||||||
|
```bash
|
||||||
|
docker logs -f voicebox
|
||||||
|
|
||||||
|
# Or with compose
|
||||||
|
docker compose logs -f voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- [ ] Publish official images to GitHub Container Registry
|
||||||
|
- [ ] Add Kubernetes Helm charts
|
||||||
|
- [ ] Create Docker Desktop extension
|
||||||
|
- [ ] Add automated vulnerability scanning
|
||||||
|
- [ ] Support ARM64 builds for Raspberry Pi / Apple Silicon
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Help improve Docker support:
|
||||||
|
1. Test on different platforms (AMD GPU, ARM64, etc.)
|
||||||
|
2. Submit Dockerfile optimizations
|
||||||
|
3. Share deployment configurations
|
||||||
|
4. Report issues: [GitHub Issues](https://github.com/jamiepine/voicebox/issues)
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [Docker Documentation](https://docs.docker.com)
|
||||||
|
- [NVIDIA Container Toolkit](https://github.com/NVIDIA/nvidia-docker)
|
||||||
|
- [AMD ROCm Docker](https://rocm.docs.amd.com/projects/install-on-linux/en/latest/how-to/docker.html)
|
||||||
|
- [Docker Compose Reference](https://docs.docker.com/compose/compose-file/)
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
# External Provider Support
|
||||||
|
|
||||||
|
**Status:** Planned for v0.2.0
|
||||||
|
**Discussion:** [Reddit Thread](https://reddit.com/r/LocalLLaMA/...)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
External provider support allows you to connect Voicebox to remotely-hosted TTS and Whisper services instead of running models locally. This is useful for:
|
||||||
|
|
||||||
|
- **Existing GPU Infrastructure**: You already have Qwen3-TTS running on a GPU server
|
||||||
|
- **AMD GPU Users**: Run models on your AMD hardware, use Voicebox as the UI
|
||||||
|
- **Cloud Deployments**: Host models on Modal, Replicate, RunPod, etc.
|
||||||
|
- **Team Sharing**: Multiple users share one GPU server running models
|
||||||
|
- **Mixed Deployments**: Local Whisper + remote TTS, or vice versa
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ HTTP/API ┌──────────────────┐
|
||||||
|
│ Voicebox UI │ ───────────────────────> │ Your TTS Server │
|
||||||
|
│ + Backend │ │ (Qwen3-TTS on │
|
||||||
|
│ │ <─────────────────────── │ AMD/NVIDIA GPU)│
|
||||||
|
│ - Profiles │ Audio + Metadata └──────────────────┘
|
||||||
|
│ - History │
|
||||||
|
│ - Audio Edit │ HTTP/API ┌──────────────────┐
|
||||||
|
│ - UI │ ───────────────────────> │ Whisper Service │
|
||||||
|
└─────────────────┘ │ (OpenAI API or │
|
||||||
|
│ self-hosted) │
|
||||||
|
└──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**What Voicebox Still Handles:**
|
||||||
|
- Voice profile management
|
||||||
|
- Generation history
|
||||||
|
- Audio trimming/editing
|
||||||
|
- Multi-track story editor
|
||||||
|
- UI/UX layer
|
||||||
|
|
||||||
|
**What External Providers Handle:**
|
||||||
|
- Model inference (TTS generation, transcription)
|
||||||
|
- GPU allocation
|
||||||
|
- Model loading/caching
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# TTS Provider
|
||||||
|
TTS_MODE=remote # local | remote
|
||||||
|
TTS_REMOTE_URL=http://192.168.1.100:8000 # Your TTS server URL
|
||||||
|
TTS_API_KEY=your-api-key # Optional authentication
|
||||||
|
|
||||||
|
# Whisper Provider
|
||||||
|
WHISPER_MODE=openai-api # local | openai-api | remote
|
||||||
|
WHISPER_REMOTE_URL=http://localhost:9000 # For self-hosted Whisper
|
||||||
|
OPENAI_API_KEY=sk-... # For OpenAI Whisper API
|
||||||
|
```
|
||||||
|
|
||||||
|
### Voicebox Config UI (Planned)
|
||||||
|
|
||||||
|
Settings page will include:
|
||||||
|
- Provider selection dropdowns
|
||||||
|
- URL/API key inputs
|
||||||
|
- Connection test button
|
||||||
|
- Latency/status indicators
|
||||||
|
|
||||||
|
## Hosting External Services
|
||||||
|
|
||||||
|
### Option 1: Simple FastAPI Server (Recommended)
|
||||||
|
|
||||||
|
Create a lightweight server to expose your local Qwen3-TTS model:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# tts_server.py
|
||||||
|
from fastapi import FastAPI, UploadFile, File
|
||||||
|
from qwen_tts import Qwen3TTSModel
|
||||||
|
import numpy as np
|
||||||
|
import base64
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
model = Qwen3TTSModel.from_pretrained(
|
||||||
|
"Qwen/Qwen3-TTS-12Hz-1.7B-Base",
|
||||||
|
device_map="cuda" # or "cpu" for AMD ROCm: use torch+rocm
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/v1/generate")
|
||||||
|
async def generate(
|
||||||
|
text: str,
|
||||||
|
voice_prompt: dict,
|
||||||
|
language: str = "en",
|
||||||
|
seed: int = None
|
||||||
|
):
|
||||||
|
"""Generate speech from text using voice prompt."""
|
||||||
|
audio, sample_rate = model.generate_voice_clone(
|
||||||
|
text=text,
|
||||||
|
voice_clone_prompt=voice_prompt,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return as base64 for transport
|
||||||
|
audio_bytes = audio.tobytes()
|
||||||
|
return {
|
||||||
|
"audio": base64.b64encode(audio_bytes).decode(),
|
||||||
|
"sample_rate": sample_rate,
|
||||||
|
"dtype": str(audio.dtype)
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.post("/v1/create_voice_prompt")
|
||||||
|
async def create_voice_prompt(
|
||||||
|
audio: UploadFile = File(...),
|
||||||
|
reference_text: str = ""
|
||||||
|
):
|
||||||
|
"""Create voice prompt from reference audio."""
|
||||||
|
# Save uploaded audio temporarily
|
||||||
|
audio_path = f"/tmp/{audio.filename}"
|
||||||
|
with open(audio_path, "wb") as f:
|
||||||
|
f.write(await audio.read())
|
||||||
|
|
||||||
|
# Create voice prompt
|
||||||
|
voice_prompt = model.create_voice_clone_prompt(
|
||||||
|
ref_audio=audio_path,
|
||||||
|
ref_text=reference_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"voice_prompt": voice_prompt}
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"model": "Qwen3-TTS-12Hz-1.7B-Base",
|
||||||
|
"device": str(model.device)
|
||||||
|
}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run it:**
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
pip install fastapi uvicorn qwen-tts torch
|
||||||
|
|
||||||
|
# For AMD GPUs, use ROCm PyTorch:
|
||||||
|
pip install torch --index-url https://download.pytorch.org/whl/rocm6.4
|
||||||
|
|
||||||
|
# Start server
|
||||||
|
python tts_server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: vLLM (If Supported)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-Base \
|
||||||
|
--host 0.0.0.0 \
|
||||||
|
--port 8000 \
|
||||||
|
--gpu-memory-utilization 0.9
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: Cloud Platforms
|
||||||
|
|
||||||
|
**Modal.com Example:**
|
||||||
|
```python
|
||||||
|
import modal
|
||||||
|
|
||||||
|
app = modal.App("qwen-tts")
|
||||||
|
image = modal.Image.debian_slim().pip_install("qwen-tts", "torch")
|
||||||
|
|
||||||
|
@app.function(gpu="A10G", image=image)
|
||||||
|
@modal.web_endpoint(method="POST")
|
||||||
|
def generate(text: str, voice_prompt: dict):
|
||||||
|
from qwen_tts import Qwen3TTSModel
|
||||||
|
model = Qwen3TTSModel.from_pretrained("Qwen/Qwen3-TTS-12Hz-1.7B-Base")
|
||||||
|
audio, sr = model.generate_voice_clone(text, voice_prompt)
|
||||||
|
return {"audio": audio.tolist(), "sample_rate": sr}
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy: `modal deploy tts_server.py`
|
||||||
|
Get URL: `https://yourapp--generate.modal.run`
|
||||||
|
|
||||||
|
## API Specification
|
||||||
|
|
||||||
|
External TTS providers must implement these endpoints:
|
||||||
|
|
||||||
|
### `POST /v1/generate`
|
||||||
|
|
||||||
|
Generate speech from text.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "Hello, this is a test.",
|
||||||
|
"voice_prompt": { /* voice prompt object */ },
|
||||||
|
"language": "en",
|
||||||
|
"seed": 12345
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"audio": "base64-encoded-audio-bytes",
|
||||||
|
"sample_rate": 24000,
|
||||||
|
"dtype": "float32"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /v1/create_voice_prompt`
|
||||||
|
|
||||||
|
Create a voice prompt from reference audio.
|
||||||
|
|
||||||
|
**Request:** (multipart/form-data)
|
||||||
|
- `audio`: Audio file upload
|
||||||
|
- `reference_text`: Transcript of the audio
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"voice_prompt": { /* voice prompt object */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /health`
|
||||||
|
|
||||||
|
Health check endpoint.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"model": "Qwen3-TTS-12Hz-1.7B-Base",
|
||||||
|
"device": "cuda:0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Whisper External Providers
|
||||||
|
|
||||||
|
### OpenAI Whisper API
|
||||||
|
|
||||||
|
Simply set:
|
||||||
|
```bash
|
||||||
|
WHISPER_MODE=openai-api
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
```
|
||||||
|
|
||||||
|
Voicebox will use OpenAI's Whisper API automatically.
|
||||||
|
|
||||||
|
### Self-Hosted Whisper
|
||||||
|
|
||||||
|
Run your own Whisper server:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# whisper_server.py
|
||||||
|
from fastapi import FastAPI, UploadFile, File
|
||||||
|
from transformers import WhisperProcessor, WhisperForConditionalGeneration
|
||||||
|
import librosa
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
processor = WhisperProcessor.from_pretrained("openai/whisper-base")
|
||||||
|
model = WhisperForConditionalGeneration.from_pretrained("openai/whisper-base")
|
||||||
|
|
||||||
|
@app.post("/v1/transcribe")
|
||||||
|
async def transcribe(audio: UploadFile = File(...), language: str = None):
|
||||||
|
# Load audio
|
||||||
|
audio_path = f"/tmp/{audio.filename}"
|
||||||
|
with open(audio_path, "wb") as f:
|
||||||
|
f.write(await audio.read())
|
||||||
|
|
||||||
|
audio_data, sr = librosa.load(audio_path, sr=16000)
|
||||||
|
|
||||||
|
# Process
|
||||||
|
inputs = processor(audio_data, sampling_rate=16000, return_tensors="pt")
|
||||||
|
predicted_ids = model.generate(inputs["input_features"])
|
||||||
|
transcription = processor.batch_decode(predicted_ids, skip_special_tokens=True)[0]
|
||||||
|
|
||||||
|
return {"text": transcription}
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure Voicebox:
|
||||||
|
```bash
|
||||||
|
WHISPER_MODE=remote
|
||||||
|
WHISPER_REMOTE_URL=http://localhost:9000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
### 1. AMD GPU User with Existing Setup
|
||||||
|
|
||||||
|
**Scenario:** You have a Radeon 7900 XTX running Qwen3-TTS on Linux.
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
1. Run `tts_server.py` on your AMD box (ROCm PyTorch)
|
||||||
|
2. Configure Voicebox: `TTS_MODE=remote`, `TTS_REMOTE_URL=http://amd-box:8000`
|
||||||
|
3. Use Voicebox UI for profiles, generation, editing
|
||||||
|
4. TTS happens on your AMD GPU
|
||||||
|
|
||||||
|
### 2. Team Deployment
|
||||||
|
|
||||||
|
**Scenario:** 5 team members, 1 GPU server.
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
1. Deploy TTS server on shared GPU box
|
||||||
|
2. Each person runs Voicebox desktop app locally
|
||||||
|
3. All point to same `TTS_REMOTE_URL`
|
||||||
|
4. Profiles and history stay local per user
|
||||||
|
5. GPU usage is shared
|
||||||
|
|
||||||
|
### 3. Hybrid Local/Remote
|
||||||
|
|
||||||
|
**Scenario:** Fast local Whisper, heavy TTS on cloud.
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
```bash
|
||||||
|
TTS_MODE=remote
|
||||||
|
TTS_REMOTE_URL=https://your-modal-app.modal.run
|
||||||
|
|
||||||
|
WHISPER_MODE=local # Fast transcription on your CPU
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. OpenAI Whisper + Self-Hosted TTS
|
||||||
|
|
||||||
|
**Scenario:** Use OpenAI's API for transcription, run TTS locally.
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
```bash
|
||||||
|
TTS_MODE=local
|
||||||
|
|
||||||
|
WHISPER_MODE=openai-api
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
Add API key authentication to your external server:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import Header, HTTPException
|
||||||
|
|
||||||
|
API_KEY = "your-secret-key"
|
||||||
|
|
||||||
|
async def verify_api_key(x_api_key: str = Header(...)):
|
||||||
|
if x_api_key != API_KEY:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||||
|
|
||||||
|
@app.post("/v1/generate", dependencies=[Depends(verify_api_key)])
|
||||||
|
async def generate(...):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure Voicebox:
|
||||||
|
```bash
|
||||||
|
TTS_API_KEY=your-secret-key
|
||||||
|
```
|
||||||
|
|
||||||
|
### Network Security
|
||||||
|
|
||||||
|
- **VPN/Tailscale**: Use private network for remote servers
|
||||||
|
- **HTTPS**: Use reverse proxy (nginx/Caddy) with SSL certificates
|
||||||
|
- **Firewall**: Restrict access to known IPs
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
Protect your external server:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from slowapi import Limiter
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
app.state.limiter = limiter
|
||||||
|
|
||||||
|
@app.post("/v1/generate")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def generate(...):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### Latency
|
||||||
|
|
||||||
|
External providers add network latency:
|
||||||
|
- **Local network**: ~10-50ms overhead (negligible)
|
||||||
|
- **Same datacenter**: ~1-5ms overhead
|
||||||
|
- **Cross-region cloud**: 50-200ms+ overhead
|
||||||
|
|
||||||
|
For real-time applications, keep TTS server on local network or same cloud region.
|
||||||
|
|
||||||
|
### Caching
|
||||||
|
|
||||||
|
Implement response caching on external server:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1000)
|
||||||
|
def get_cached_generation(text, voice_prompt_hash, language, seed):
|
||||||
|
return model.generate_voice_clone(text, voice_prompt)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Load Balancing
|
||||||
|
|
||||||
|
For high-traffic deployments, run multiple TTS servers behind a load balancer:
|
||||||
|
|
||||||
|
```
|
||||||
|
Voicebox ──> Load Balancer ──> TTS Server 1 (GPU 1)
|
||||||
|
├──> TTS Server 2 (GPU 2)
|
||||||
|
└──> TTS Server 3 (GPU 3)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- [ ] **Provider Marketplace**: Built-in directory of compatible providers
|
||||||
|
- [ ] **Automatic Fallback**: If remote fails, fallback to local
|
||||||
|
- [ ] **Cost Tracking**: Monitor API usage and costs
|
||||||
|
- [ ] **Performance Metrics**: Latency, throughput dashboards
|
||||||
|
- [ ] **Multi-Provider**: Use different providers for different voices/languages
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
If you build an external provider, please share:
|
||||||
|
1. Server implementation
|
||||||
|
2. Performance benchmarks
|
||||||
|
3. Deployment guide
|
||||||
|
|
||||||
|
Submit to: [GitHub Discussions](https://github.com/jamiepine/voicebox/discussions)
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
- **Discord**: [Join the community](https://discord.gg/...)
|
||||||
|
- **GitHub**: [Open an issue](https://github.com/jamiepine/voicebox/issues)
|
||||||
|
- **Docs**: [Full documentation](https://voicebox.sh/docs)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/landing",
|
"name": "@voicebox/landing",
|
||||||
"version": "0.1.4",
|
"version": "0.1.9",
|
||||||
"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",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
@@ -99,7 +99,7 @@ export default function Home() {
|
|||||||
<div className="flex justify-center lg:hidden my-8">
|
<div className="flex justify-center lg:hidden my-8">
|
||||||
<div className="w-full max-w-2xl">
|
<div className="w-full max-w-2xl">
|
||||||
<Image
|
<Image
|
||||||
src="/VoiceBoxAppScreenshot.webp"
|
src="/assets/app-screenshot-1.webp"
|
||||||
alt="Voicebox Application Screenshot"
|
alt="Voicebox Application Screenshot"
|
||||||
width={1920}
|
width={1920}
|
||||||
height={1080}
|
height={1080}
|
||||||
@@ -180,7 +180,7 @@ export default function Home() {
|
|||||||
<div className="hidden lg:block relative">
|
<div className="hidden lg:block relative">
|
||||||
<div className="absolute right-0 top-0 -mt-10 w-[200%] -mr-[100%]">
|
<div className="absolute right-0 top-0 -mt-10 w-[200%] -mr-[100%]">
|
||||||
<Image
|
<Image
|
||||||
src="/VoiceBoxAppScreenshot.webp"
|
src="/assets/app-screenshot-1.webp"
|
||||||
alt="Voicebox Application Screenshot"
|
alt="Voicebox Application Screenshot"
|
||||||
width={1920}
|
width={1920}
|
||||||
height={1080}
|
height={1080}
|
||||||
@@ -193,6 +193,41 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Screenshots Section */}
|
||||||
|
<section className="py-12 sm:py-16 md:py-20">
|
||||||
|
<div className="w-full md:w-[150%] md:-ml-[25%]">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 px-8">
|
||||||
|
<div className="w-full">
|
||||||
|
<Image
|
||||||
|
src="/assets/app-screenshot-2.webp"
|
||||||
|
alt="Voicebox Screenshot 2"
|
||||||
|
width={1920}
|
||||||
|
height={1080}
|
||||||
|
className="w-full h-auto rounded-lg shadow-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full">
|
||||||
|
<Image
|
||||||
|
src="/assets/app-screenshot-1.webp"
|
||||||
|
alt="Voicebox Screenshot 1"
|
||||||
|
width={1920}
|
||||||
|
height={1080}
|
||||||
|
className="w-full h-auto rounded-lg shadow-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full">
|
||||||
|
<Image
|
||||||
|
src="/assets/app-screenshot-3.webp"
|
||||||
|
alt="Voicebox Screenshot 3"
|
||||||
|
width={1920}
|
||||||
|
height={1080}
|
||||||
|
className="w-full h-auto rounded-lg shadow-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Demo Video Section */}
|
{/* Demo Video Section */}
|
||||||
<section className="py-12 sm:py-16 md:py-20">
|
<section className="py-12 sm:py-16 md:py-20">
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||||
@@ -207,7 +242,7 @@ export default function Home() {
|
|||||||
controls
|
controls
|
||||||
playsInline
|
playsInline
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
poster="/VoiceBoxAppScreenshot.webp"
|
poster="/assets/app-screenshot-1.webp"
|
||||||
>
|
>
|
||||||
<source
|
<source
|
||||||
src="/voicebox-demo.webm"
|
src="/voicebox-demo.webm"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "voicebox",
|
"name": "voicebox",
|
||||||
"version": "0.1.4",
|
"version": "0.1.9",
|
||||||
"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.4",
|
"version": "0.1.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Generated
+219
-6
@@ -406,6 +406,16 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -429,9 +439,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types 0.5.0",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -442,7 +452,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -889,6 +899,15 @@ version = "1.0.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||||
|
dependencies = [
|
||||||
|
"foreign-types-shared 0.1.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -896,7 +915,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foreign-types-macros",
|
"foreign-types-macros",
|
||||||
"foreign-types-shared",
|
"foreign-types-shared 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -910,6 +929,12 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types-shared"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types-shared"
|
name = "foreign-types-shared"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -942,6 +967,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1311,6 +1337,25 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h2"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"fnv",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"http",
|
||||||
|
"indexmap 2.13.0",
|
||||||
|
"slab",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
@@ -1408,6 +1453,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -1436,6 +1482,22 @@ dependencies = [
|
|||||||
"webpki-roots",
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-tls"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.19"
|
version = "0.1.19"
|
||||||
@@ -1455,9 +1517,11 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"windows-registry",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2024,6 +2088,23 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "native-tls"
|
||||||
|
version = "0.2.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"openssl",
|
||||||
|
"openssl-probe",
|
||||||
|
"openssl-sys",
|
||||||
|
"schannel",
|
||||||
|
"security-framework",
|
||||||
|
"security-framework-sys",
|
||||||
|
"tempfile",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -2430,6 +2511,50 @@ dependencies = [
|
|||||||
"pathdiff",
|
"pathdiff",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl"
|
||||||
|
version = "0.10.75"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"cfg-if",
|
||||||
|
"foreign-types 0.3.2",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"openssl-macros",
|
||||||
|
"openssl-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-macros"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.114",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-probe"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-sys"
|
||||||
|
version = "0.9.111"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -3077,16 +3202,22 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"encoding_rs",
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-rustls",
|
"hyper-rustls",
|
||||||
|
"hyper-tls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
"mime",
|
||||||
|
"native-tls",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn",
|
"quinn",
|
||||||
@@ -3097,6 +3228,7 @@ dependencies = [
|
|||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
@@ -3232,6 +3364,15 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "schannel"
|
||||||
|
version = "0.1.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schemars"
|
name = "schemars"
|
||||||
version = "0.8.22"
|
version = "0.8.22"
|
||||||
@@ -3295,6 +3436,29 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ccf069cb109cf8e01ebdca0d55dfce45dbbf669e8c56ed5c62150b056d3ec9f"
|
checksum = "6ccf069cb109cf8e01ebdca0d55dfce45dbbf669e8c56ed5c62150b056d3ec9f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework"
|
||||||
|
version = "2.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
"security-framework-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework-sys"
|
||||||
|
version = "2.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "selectors"
|
name = "selectors"
|
||||||
version = "0.24.0"
|
version = "0.24.0"
|
||||||
@@ -3931,6 +4095,27 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"system-configuration-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration-sys"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "system-deps"
|
name = "system-deps"
|
||||||
version = "6.2.2"
|
version = "6.2.2"
|
||||||
@@ -3952,7 +4137,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"block2",
|
"block2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dispatch",
|
"dispatch",
|
||||||
@@ -4495,6 +4680,16 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-native-tls"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||||
|
dependencies = [
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.4"
|
||||||
@@ -4826,6 +5021,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version-compare"
|
name = "version-compare"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -4840,7 +5041,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "voicebox"
|
name = "voicebox"
|
||||||
version = "0.1.3"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
@@ -4848,6 +5049,7 @@ dependencies = [
|
|||||||
"cpal",
|
"cpal",
|
||||||
"hound",
|
"hound",
|
||||||
"objc",
|
"objc",
|
||||||
|
"reqwest",
|
||||||
"scopeguard",
|
"scopeguard",
|
||||||
"screencapturekit",
|
"screencapturekit",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -5330,6 +5532,17 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-registry"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
"windows-result 0.4.1",
|
||||||
|
"windows-strings 0.5.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "voicebox"
|
name = "voicebox"
|
||||||
version = "0.1.4"
|
version = "0.1.9"
|
||||||
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 = ""
|
||||||
@@ -20,6 +20,7 @@ tauri-plugin-shell = "2.0"
|
|||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||||
hound = "3.5"
|
hound = "3.5"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
cpal = "0.15"
|
cpal = "0.15"
|
||||||
|
|||||||
Binary file not shown.
@@ -5948,6 +5948,36 @@
|
|||||||
"const": "fs:write-files",
|
"const": "fs:write-files",
|
||||||
"markdownDescription": "This enables all file write related commands without any pre-configured accessible paths."
|
"markdownDescription": "This enables all file write related commands without any pre-configured accessible paths."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "process:default",
|
||||||
|
"markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the exit command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "process:allow-exit",
|
||||||
|
"markdownDescription": "Enables the exit command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the restart command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "process:allow-restart",
|
||||||
|
"markdownDescription": "Enables the restart command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the exit command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "process:deny-exit",
|
||||||
|
"markdownDescription": "Denies the exit command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the restart command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "process:deny-restart",
|
||||||
|
"markdownDescription": "Denies the restart command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
+154
-11
@@ -303,6 +303,60 @@ async fn start_server(
|
|||||||
Ok(format!("http://127.0.0.1:{}", SERVER_PORT))
|
Ok(format!("http://127.0.0.1:{}", SERVER_PORT))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if a Windows process is still running
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn is_process_running(pid: u32) -> bool {
|
||||||
|
use std::process::Command;
|
||||||
|
if let Ok(output) = Command::new("tasklist")
|
||||||
|
.args(["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"])
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
// If process exists, tasklist returns it in output
|
||||||
|
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||||
|
return !output_str.trim().is_empty() && output_str.contains(&pid.to_string());
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kill entire Windows process tree by enumerating children
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn kill_windows_process_tree(parent_pid: u32) -> Result<(), String> {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
// Find all child processes using WMIC
|
||||||
|
let output = Command::new("wmic")
|
||||||
|
.args([
|
||||||
|
"process",
|
||||||
|
"where",
|
||||||
|
&format!("ParentProcessId={}", parent_pid),
|
||||||
|
"get",
|
||||||
|
"ProcessId"
|
||||||
|
])
|
||||||
|
.output();
|
||||||
|
|
||||||
|
if let Ok(output) = output {
|
||||||
|
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||||
|
for line in output_str.lines().skip(1) { // Skip header
|
||||||
|
if let Ok(child_pid) = line.trim().parse::<u32>() {
|
||||||
|
println!("Found child process: {}", child_pid);
|
||||||
|
// Recursively kill child's children
|
||||||
|
let _ = kill_windows_process_tree(child_pid);
|
||||||
|
// Kill the child
|
||||||
|
let _ = Command::new("taskkill")
|
||||||
|
.args(["/PID", &child_pid.to_string(), "/F"])
|
||||||
|
.output();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill the parent process
|
||||||
|
let _ = Command::new("taskkill")
|
||||||
|
.args(["/PID", &parent_pid.to_string(), "/F"])
|
||||||
|
.output();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
|
async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
|
||||||
let pid = state.server_pid.lock().unwrap().take();
|
let pid = state.server_pid.lock().unwrap().take();
|
||||||
@@ -332,13 +386,59 @@ async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
|
|||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
use std::process::Command;
|
// Layer 1: Try graceful HTTP shutdown first
|
||||||
let _ = Command::new("taskkill")
|
println!("Attempting graceful shutdown via HTTP...");
|
||||||
.args(["/PID", &pid.to_string(), "/T", "/F"])
|
let client = reqwest::blocking::Client::builder()
|
||||||
.output();
|
.timeout(std::time::Duration::from_secs(2))
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let shutdown_result = client
|
||||||
|
.post(&format!("http://127.0.0.1:{}/shutdown", SERVER_PORT))
|
||||||
|
.send();
|
||||||
|
|
||||||
|
if shutdown_result.is_ok() {
|
||||||
|
println!("HTTP shutdown sent, waiting for graceful exit...");
|
||||||
|
// Wait up to 3 seconds for graceful shutdown
|
||||||
|
for i in 0..30 {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
if !is_process_running(pid) {
|
||||||
|
println!("Process exited gracefully after {}ms", i * 100);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("Graceful shutdown timed out, forcing kill...");
|
||||||
|
} else {
|
||||||
|
println!("HTTP shutdown failed, forcing kill...");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 2: Kill process tree with enumeration
|
||||||
|
println!("Killing process tree for wrapper PID {}...", pid);
|
||||||
|
kill_windows_process_tree(pid)?;
|
||||||
|
|
||||||
|
// Layer 3: Verify and kill by name if still running
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
|
if is_process_running(pid) {
|
||||||
|
println!("Process tree kill failed, killing by name...");
|
||||||
|
use std::process::Command;
|
||||||
|
let _ = Command::new("taskkill")
|
||||||
|
.args(["/IM", "voicebox-server.exe", "/T", "/F"])
|
||||||
|
.output();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 4: Final verification
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
|
if is_process_running(pid) {
|
||||||
|
eprintln!("WARNING: Failed to kill server after all attempts");
|
||||||
|
} else {
|
||||||
|
println!("Server killed successfully");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
println!("stop_server: Process group kill completed");
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("stop_server: Process group kill completed");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -560,11 +660,54 @@ pub fn run() {
|
|||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
// On Windows, use taskkill with /T to kill child processes
|
// Layer 1: Try graceful HTTP shutdown first
|
||||||
use std::process::Command;
|
println!("Attempting graceful shutdown via HTTP...");
|
||||||
let _ = Command::new("taskkill")
|
let client = reqwest::blocking::Client::builder()
|
||||||
.args(["/PID", &pid.to_string(), "/T", "/F"])
|
.timeout(std::time::Duration::from_secs(2))
|
||||||
.output();
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let shutdown_result = client
|
||||||
|
.post(&format!("http://127.0.0.1:{}/shutdown", SERVER_PORT))
|
||||||
|
.send();
|
||||||
|
|
||||||
|
if shutdown_result.is_ok() {
|
||||||
|
println!("HTTP shutdown sent, waiting for graceful exit...");
|
||||||
|
// Wait up to 3 seconds for graceful shutdown
|
||||||
|
for i in 0..30 {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
if !is_process_running(pid) {
|
||||||
|
println!("Process exited gracefully after {}ms", i * 100);
|
||||||
|
println!("Server process tree kill completed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("Graceful shutdown timed out, forcing kill...");
|
||||||
|
} else {
|
||||||
|
println!("HTTP shutdown failed, forcing kill...");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 2: Kill process tree with enumeration
|
||||||
|
println!("Killing process tree for wrapper PID {}...", pid);
|
||||||
|
let _ = kill_windows_process_tree(pid);
|
||||||
|
|
||||||
|
// Layer 3: Verify and kill by name if still running
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
|
if is_process_running(pid) {
|
||||||
|
println!("Process tree kill failed, killing by name...");
|
||||||
|
use std::process::Command;
|
||||||
|
let _ = Command::new("taskkill")
|
||||||
|
.args(["/IM", "voicebox-server.exe", "/T", "/F"])
|
||||||
|
.output();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 4: Final verification
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
|
if is_process_running(pid) {
|
||||||
|
eprintln!("WARNING: Failed to kill server after all attempts");
|
||||||
|
} else {
|
||||||
|
println!("Server killed successfully");
|
||||||
|
}
|
||||||
println!("Server process tree kill completed");
|
println!("Server process tree kill completed");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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.4",
|
"version": "0.1.9",
|
||||||
"identifier": "sh.voicebox.app",
|
"identifier": "sh.voicebox.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/web",
|
"name": "@voicebox/web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.4",
|
"version": "0.1.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Reference in New Issue
Block a user