Compare commits

..
Author SHA1 Message Date
Jamie Pine 229841e05e Add GPU type information to health check response
- Updated the health check endpoint to include the type of GPU available (CUDA or MPS).
- Modified the HealthResponse model to accommodate the new gpu_type field, enhancing the response with detailed GPU information.
- This change improves the clarity of system capabilities for users and developers.
2026-01-29 03:12:11 -08:00
Jamie Pine 123e8215e4 Merge branch 'main' into windows-server-shutdown 2026-01-29 03:00:08 -08:00
Jamie Pine 2a3afec2ca Implement graceful shutdown for the server and enhance process management on Windows
- Added a new `/shutdown` endpoint to allow graceful server shutdown via HTTP.
- Implemented process tree management functions to handle child processes during shutdown on Windows.
- Updated the `stop_server` function to attempt graceful shutdown before forcefully terminating processes.
- Enhanced error handling and logging for shutdown operations.
2026-01-29 02:58:21 -08:00
Jamie Pine 99ddd5a0b4 Add asynchronous model download handling for TTS and Whisper models
- Implemented background tasks for downloading TTS and Whisper models to prevent blocking HTTP responses.
- Enhanced error handling during model downloads, providing users with real-time feedback on download status.
- Updated HTTP responses to indicate when models are being downloaded, improving user experience during model initialization.
2026-01-29 02:55:17 -08:00
Jamie Pine 8d730621bc Refactor model download handling to use background tasks
- Moved model download logic into a separate asynchronous function to allow non-blocking HTTP responses.
- Improved error handling by tracking download status and reporting errors without interrupting the main request flow.
- The frontend is now expected to poll the progress endpoint for download status updates.
2026-01-29 02:42:02 -08:00
Jamie Pine e23118f610 Bump version: 0.1.7 → 0.1.8 2026-01-29 02:21:01 -08:00
Jamie Pine d4bfdc0d68 Update version handling in backend and improve HuggingFace cache management
- Added __version__ variable in backend/__init__.py to centralize versioning.
- Updated main.py to use __version__ for API versioning in the FastAPI app.
- Enhanced cache directory handling by utilizing HuggingFace's constants for improved compatibility across platforms.
2026-01-29 02:20:33 -08:00
Jamie Pine 116c108906 Update screenshot asset in landing page for consistency with current design 2026-01-29 00:06:06 -08:00
Jamie Pine 2d23c8e06a Swap screenshot assets in landing page for improved visual representation
- Replaced app screenshot paths to ensure correct images are displayed.
- Adjusted alt text for screenshots to accurately reflect their content.
2026-01-29 00:06:00 -08:00
Jamie Pine 3973a59ba3 Revise README to clarify Voicebox features and benefits
- Changed section title from "Why Voicebox?" to "What is Voicebox?" for better clarity.
- Expanded description to emphasize local-first voice cloning capabilities and professional tools.
- Highlighted privacy, model flexibility, and native performance as key advantages over cloud services.
2026-01-28 23:59:47 -08:00
Jamie Pine d9aa75253a Enhance README with new features and multi-track editor details
- Added multi-sample support for higher quality cloning.
- Introduced a new Stories Editor section with features for multi-track composition, inline audio editing, auto-playback, and voice mixing.
- Updated recording section to include system audio capture for macOS and Windows.
2026-01-28 23:55:44 -08:00
Jamie Pine b22bf36565 Update README and landing page with new screenshots; bump version to 0.1.7
- Replaced existing screenshot paths in README and landing page with new assets.
- Added additional screenshots to the landing page for enhanced visual representation.
- Updated version in Cargo.lock from 0.1.6 to 0.1.7.
2026-01-28 23:51:43 -08:00
Jamie Pine 33f4ed9b44 Bump version: 0.1.6 → 0.1.7 2026-01-28 22:28:18 -08:00
Jamie Pine cc37e04221 Refactor HistoryTable and SampleList components for improved code consistency
- Cleaned up formatting in HistoryTable for better readability.
- Adjusted import statements in SampleList to maintain consistent structure.
2026-01-28 22:28:01 -08:00
Jamie Pine 2b4fbe5173 Refactor AudioPlayer and related components to support conditional auto-play functionality
- Updated AudioPlayer to auto-play only if the shouldAutoPlay flag is set, enhancing user control over playback.
- Refactored HistoryTable, SampleList, and useGenerationForm to utilize setAudioWithAutoPlay for consistent audio loading and playback behavior.
- Improved user experience by ensuring audio is only played when explicitly intended, reducing unexpected playback.
2026-01-28 22:27:37 -08:00
Jamie Pine ea943876dc formatting 2026-01-28 22:23:27 -08:00
Jamie Pine b55d8cc567 Implement auto-activation of stories in StoryTrackEditor and improve playback state management
- Added useEffect to automatically activate the story when the editor is shown, ensuring the playhead is visible.
- Introduced setActiveStory function in storyStore to manage story activation without playback.
- Updated playback state checks to reflect the current playing status accurately.
- Enhanced UI to always display the playhead for better user experience during playback.
2026-01-28 22:22:47 -08:00
Jamie Pine 036d90dc8e Enhance story item management with trimming, splitting, and duplication features
- Updated StoryTrackEditor and StoryContent components to support trimming and splitting of story items.
- Introduced new API endpoints for trimming, splitting, and duplicating story items, enhancing item management capabilities.
- Refactored related hooks and state management to accommodate new functionalities.
- Improved data models to include trim start and end times for better audio playback control.
- Enhanced UI interactions for selecting and managing story items within the track editor.
2026-01-28 22:16:53 -08:00
Jamie PineandGitHub 27ae6dfbab Merge pull request #3 from jamiepine/stories
Stories
2026-01-28 21:18:19 -08:00
33 changed files with 1825 additions and 359 deletions
+4 -4
View File
@@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 0.1.6 current_version = 0.1.8
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}"
+32 -9
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@voicebox/app", "name": "@voicebox/app",
"version": "0.1.6", "version": "0.1.8",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+16 -8
View File
@@ -357,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
debug.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
+8 -5
View File
@@ -53,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);
@@ -77,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));
} }
}; };
@@ -233,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
@@ -241,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>
@@ -131,13 +131,13 @@ export function StoryContent() {
} }
}, [isPlaying]); }, [isPlaying]);
const handleRemoveItem = (generationId: string) => { const handleRemoveItem = (itemId: string) => {
if (!story) return; if (!story) return;
removeItem.mutate( removeItem.mutate(
{ {
storyId: story.id, storyId: story.id,
generationId, itemId,
}, },
{ {
onError: (error) => { onError: (error) => {
@@ -360,7 +360,7 @@ export function StoryContent() {
item={item} item={item}
storyId={story.id} storyId={story.id}
index={index} index={index}
onRemove={() => handleRemoveItem(item.generation_id)} onRemove={() => handleRemoveItem(item.id)}
currentTimeMs={currentTimeMs} currentTimeMs={currentTimeMs}
isPlaying={isPlaying && playbackStoryId === story.id} isPlaying={isPlaying && playbackStoryId === story.id}
/> />
+646 -213
View File
@@ -1,21 +1,59 @@
import { GripHorizontal, Minus, Pause, Play, Plus, Square } from 'lucide-react'; import {
Copy,
GripHorizontal,
Minus,
Pause,
Play,
Plus,
Scissors,
Square,
Trash2,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js'; import WaveSurfer from 'wavesurfer.js';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import { useMoveStoryItem } from '@/lib/hooks/useStories';
import { useStoryStore } from '@/stores/storyStore';
import type { StoryItemDetail } from '@/lib/api/types'; import type { StoryItemDetail } from '@/lib/api/types';
import {
useDuplicateStoryItem,
useMoveStoryItem,
useRemoveStoryItem,
useSplitStoryItem,
useTrimStoryItem,
} from '@/lib/hooks/useStories';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { useStoryStore } from '@/stores/storyStore';
// Clip waveform component // Clip waveform component with trim support
function ClipWaveform({ generationId, width }: { generationId: string; width: number }) { function ClipWaveform({
const containerRef = useRef<HTMLDivElement>(null); 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); 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(() => { useEffect(() => {
if (!containerRef.current || width < 20) return; if (!waveformRef.current || fullWaveformWidth < 20) return;
// Get CSS colors // Get CSS colors
const root = document.documentElement; const root = document.documentElement;
@@ -27,7 +65,7 @@ function ClipWaveform({ generationId, width }: { generationId: string; width: nu
const waveColor = getCSSVar('--accent-foreground'); const waveColor = getCSSVar('--accent-foreground');
const wavesurfer = WaveSurfer.create({ const wavesurfer = WaveSurfer.create({
container: containerRef.current, container: waveformRef.current,
waveColor, waveColor,
progressColor: waveColor, progressColor: waveColor,
cursorWidth: 0, cursorWidth: 0,
@@ -50,9 +88,21 @@ function ClipWaveform({ generationId, width }: { generationId: string; width: nu
wavesurfer.destroy(); wavesurfer.destroy();
wavesurferRef.current = null; wavesurferRef.current = null;
}; };
}, [generationId, width]); }, [generationId, fullWaveformWidth]);
return <div ref={containerRef} className="w-full h-full opacity-60" />; 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 { interface StoryTrackEditorProps {
@@ -80,8 +130,25 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
const resizeStartY = useRef(0); const resizeStartY = useRef(0);
const resizeStartHeight = useRef(0); const resizeStartHeight = useRef(0);
const moveItem = useMoveStoryItem(); const moveItem = useMoveStoryItem();
const trimItem = useTrimStoryItem();
const splitItem = useSplitStoryItem();
const duplicateItem = useDuplicateStoryItem();
const removeItem = useRemoveStoryItem();
const { toast } = useToast(); 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) // Track editor height from store (shared with FloatingGenerateBox)
const editorHeight = useStoryStore((state) => state.trackEditorHeight); const editorHeight = useStoryStore((state) => state.trackEditorHeight);
const setEditorHeight = useStoryStore((state) => state.setTrackEditorHeight); const setEditorHeight = useStoryStore((state) => state.setTrackEditorHeight);
@@ -89,16 +156,32 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
// Playback state // Playback state
const isPlaying = useStoryStore((state) => state.isPlaying); const isPlaying = useStoryStore((state) => state.isPlaying);
const currentTimeMs = useStoryStore((state) => state.currentTimeMs); const currentTimeMs = useStoryStore((state) => state.currentTimeMs);
const storeTotalDurationMs = useStoryStore((state) => state.totalDurationMs);
const playbackStoryId = useStoryStore((state) => state.playbackStoryId); const playbackStoryId = useStoryStore((state) => state.playbackStoryId);
const play = useStoryStore((state) => state.play); const play = useStoryStore((state) => state.play);
const pause = useStoryStore((state) => state.pause); const pause = useStoryStore((state) => state.pause);
const stop = useStoryStore((state) => state.stop); const stop = useStoryStore((state) => state.stop);
const seek = useStoryStore((state) => state.seek); const seek = useStoryStore((state) => state.seek);
const setActiveStory = useStoryStore((state) => state.setActiveStory);
const isActiveStory = playbackStoryId === storyId; const isActiveStory = playbackStoryId === storyId;
const isCurrentlyPlaying = isPlaying && isActiveStory; 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 // Sort items by start time for play
const sortedItems = useMemo(() => { const sortedItems = useMemo(() => {
return [...items].sort((a, b) => a.start_time_ms - b.start_time_ms); return [...items].sort((a, b) => a.start_time_ms - b.start_time_ms);
@@ -140,14 +223,16 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
// Calculate total duration // 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(() => { const totalDurationMs = useMemo(() => {
if (items.length === 0) return 10000; // Default 10 seconds if (items.length === 0) return 10000; // Default 10 seconds
return Math.max( return Math.max(...items.map((item) => item.start_time_ms + getEffectiveDuration(item)), 10000);
...items.map((item) => item.start_time_ms + item.duration * 1000), }, [items, getEffectiveDuration]);
10000
);
}, [items]);
// Calculate timeline width - at least full container width // Calculate timeline width - at least full container width
const contentWidth = (totalDurationMs / 1000) * pixelsPerSecond + 200; // Content width with padding const contentWidth = (totalDurationMs / 1000) * pixelsPerSecond + 200; // Content width with padding
@@ -175,15 +260,9 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
return `${minutes}:${seconds.toString().padStart(2, '0')}`; return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}; };
const msToPixels = useCallback( const msToPixels = useCallback((ms: number) => (ms / 1000) * pixelsPerSecond, [pixelsPerSecond]);
(ms: number) => (ms / 1000) * pixelsPerSecond,
[pixelsPerSecond]
);
const pixelsToMs = useCallback( const pixelsToMs = useCallback((px: number) => (px / pixelsPerSecond) * 1000, [pixelsPerSecond]);
(px: number) => (px / pixelsPerSecond) * 1000,
[pixelsPerSecond]
);
const handleZoomIn = () => { const handleZoomIn = () => {
setPixelsPerSecond((prev) => Math.min(prev * 1.5, MAX_PIXELS_PER_SECOND)); setPixelsPerSecond((prev) => Math.min(prev * 1.5, MAX_PIXELS_PER_SECOND));
@@ -194,22 +273,28 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
}; };
// Resize handlers // Resize handlers
const handleResizeStart = useCallback((e: React.MouseEvent) => { const handleResizeStart = useCallback(
e.preventDefault(); (e: React.MouseEvent) => {
setIsResizing(true); e.preventDefault();
resizeStartY.current = e.clientY; setIsResizing(true);
resizeStartHeight.current = editorHeight; resizeStartY.current = e.clientY;
}, [editorHeight]); resizeStartHeight.current = editorHeight;
},
[editorHeight],
);
const handleResizeMove = useCallback((e: MouseEvent) => { const handleResizeMove = useCallback(
if (!isResizing) return; (e: MouseEvent) => {
const deltaY = resizeStartY.current - e.clientY; if (!isResizing) return;
const newHeight = Math.min( const deltaY = resizeStartY.current - e.clientY;
MAX_EDITOR_HEIGHT, const newHeight = Math.min(
Math.max(MIN_EDITOR_HEIGHT, resizeStartHeight.current + deltaY) MAX_EDITOR_HEIGHT,
); Math.max(MIN_EDITOR_HEIGHT, resizeStartHeight.current + deltaY),
setEditorHeight(newHeight); );
}, [isResizing, setEditorHeight]); setEditorHeight(newHeight);
},
[isResizing, setEditorHeight],
);
const handleResizeEnd = useCallback(() => { const handleResizeEnd = useCallback(() => {
setIsResizing(false); setIsResizing(false);
@@ -228,17 +313,269 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
}, [isResizing, handleResizeMove, handleResizeEnd]); }, [isResizing, handleResizeMove, handleResizeEnd]);
const handleTimelineClick = (e: React.MouseEvent<HTMLDivElement>) => { const handleTimelineClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!tracksRef.current || draggingItem) return; if (!tracksRef.current || draggingItem || trimmingItem) return;
const rect = tracksRef.current.getBoundingClientRect(); const rect = tracksRef.current.getBoundingClientRect();
const x = e.clientX - rect.left + tracksRef.current.scrollLeft; const x = e.clientX - rect.left + tracksRef.current.scrollLeft;
const timeMs = Math.max(0, pixelsToMs(x)); const timeMs = Math.max(0, pixelsToMs(x));
seek(timeMs); seek(timeMs);
// Deselect clip when clicking on timeline
setSelectedClipId(null);
}; };
const handleDragStart = ( const handleClipClick = (e: React.MouseEvent, item: StoryItemDetail) => {
e: React.MouseEvent, e.stopPropagation();
item: StoryItemDetail 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 === '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]);
// 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(); e.stopPropagation();
if (!tracksRef.current) return; if (!tracksRef.current) return;
@@ -251,7 +588,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
x: rect.left - tracksRef.current.getBoundingClientRect().left + tracksRef.current.scrollLeft, x: rect.left - tracksRef.current.getBoundingClientRect().left + tracksRef.current.scrollLeft,
y: rect.top - tracksRef.current.getBoundingClientRect().top, y: rect.top - tracksRef.current.getBoundingClientRect().top,
}); });
setDraggingItem(item.generation_id); setDraggingItem(item.id);
}; };
const handleDragMove = useCallback( const handleDragMove = useCallback(
@@ -264,7 +601,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
setDragPosition({ x: Math.max(0, x), y }); setDragPosition({ x: Math.max(0, x), y });
}, },
[draggingItem, dragOffset] [draggingItem, dragOffset],
); );
const handleDragEnd = useCallback(() => { const handleDragEnd = useCallback(() => {
@@ -273,7 +610,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
return; return;
} }
const item = items.find((i) => i.generation_id === draggingItem); const item = items.find((i) => i.id === draggingItem);
if (!item) { if (!item) {
setDraggingItem(null); setDraggingItem(null);
return; return;
@@ -292,7 +629,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
moveItem.mutate( moveItem.mutate(
{ {
storyId, storyId,
generationId: item.generation_id, itemId: item.id,
data: { data: {
start_time_ms: newTimeMs, start_time_ms: newTimeMs,
track: newTrack, track: newTrack,
@@ -302,11 +639,11 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
onError: (error) => { onError: (error) => {
toast({ toast({
title: 'Failed to move item', title: 'Failed to move item',
description: error.message, description: error instanceof Error ? error.message : String(error),
variant: 'destructive', variant: 'destructive',
}); });
}, },
} },
); );
} }
@@ -318,9 +655,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
// Calculate clip position and dimensions // Calculate clip position and dimensions
const getClipStyle = (item: StoryItemDetail) => { const getClipStyle = (item: StoryItemDetail) => {
const isDragging = draggingItem === item.generation_id; const isDragging = draggingItem === item.id;
const trackIndex = getTrackIndex(item.track); const trackIndex = getTrackIndex(item.track);
const width = msToPixels(item.duration * 1000); const effectiveDuration = getEffectiveDuration(item);
const width = msToPixels(effectiveDuration);
const left = isDragging ? dragPosition.x : msToPixels(item.start_time_ms); const left = isDragging ? dragPosition.x : msToPixels(item.start_time_ms);
const top = isDragging ? dragPosition.y : trackIndex * TRACK_HEIGHT; const top = isDragging ? dragPosition.y : trackIndex * TRACK_HEIGHT;
@@ -345,176 +683,271 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
return ( 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="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}> <div
{/* Resize handle at top */} className="border-t bg-background/30 backdrop-blur-2xl overflow-hidden relative"
<button ref={containerRef}
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" /> {/* Resize handle at top */}
</button> <button
type="button"
{/* Toolbar */} 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"
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30 mt-2"> onMouseDown={handleResizeStart}
{/* Play controls - left side */} aria-label="Resize track editor"
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handlePlayPause}>
{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={!isActiveStory}>
<Square className="h-3 w-3" />
</Button>
<span className="text-xs text-muted-foreground tabular-nums ml-2">
{formatTime(isActiveStory ? currentTimeMs : 0)} / {formatTime(isActiveStory ? storeTotalDurationMs : 0)}
</span>
</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 */} <GripHorizontal className="h-3 w-3 text-muted-foreground/50 group-hover:text-muted-foreground" />
<div </button>
className="h-6 border-b bg-muted/20 sticky top-0 z-10"
style={{ width: `${timelineWidth}px` }} {/* Toolbar */}
> <div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30 mt-2">
{timeMarkers.map((ms) => ( {/* Play controls - left side */}
<div <div className="flex items-center gap-2">
key={ms} <Button variant="ghost" size="icon" className="h-7 w-7" onClick={handlePlayPause}>
className="absolute top-0 h-full flex flex-col justify-end" {isCurrentlyPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
style={{ left: `${msToPixels(ms)}px` }} </Button>
> <Button
<div className="h-2 w-px bg-border" /> variant="ghost"
<span className="text-[10px] text-muted-foreground ml-1 select-none"> size="icon"
{formatTime(ms)} className="h-7 w-7"
</span> onClick={handleStop}
</div> 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> </div>
{/* Tracks area */} {/* Clip editing controls - center */}
<div {selectedClipId && (
className="relative" <div className="flex items-center gap-1">
style={{ width: `${timelineWidth}px`, height: `${tracksAreaHeight}px` }} <Button
> variant="ghost"
{/* Track backgrounds */} size="icon"
{tracks.map((trackNumber, index) => ( className="h-7 w-7"
<div onClick={handleSplit}
key={trackNumber} title="Split at playhead (S)"
className={cn(
'absolute left-0 right-0 border-b',
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.generation_id;
const style = getClipStyle(item);
const clipWidth = msToPixels(item.duration * 1000);
return (
<button
type="button"
key={item.generation_id}
className={cn(
'absolute rounded cursor-move select-none overflow-hidden z-10',
'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'
)}
style={style}
onMouseDown={(e) => handleDragStart(e, item)}
> >
{/* Clip label */} <Scissors className="h-4 w-4" />
<div className="absolute top-0 left-1 right-1 z-10"> </Button>
<p className="text-[9px] font-medium text-accent-foreground truncate"> <Button
{item.profile_name} variant="ghost"
</p> size="icon"
</div> className="h-7 w-7"
{/* Waveform */} onClick={handleDuplicate}
<div className="absolute inset-0 top-3"> title="Duplicate (Cmd/Ctrl+D)"
<ClipWaveform generationId={item.generation_id} width={clipWidth} /> >
</div> <Copy className="h-4 w-4" />
</button> </Button>
); <Button
})} variant="ghost"
size="icon"
{/* Playhead */} className="h-7 w-7"
{isActiveStory && ( onClick={handleDelete}
<div title="Delete (Delete/Backspace)"
className="absolute top-0 bottom-0 w-1 bg-accent z-30 pointer-events-none rounded-full" >
style={{ left: `${playheadLeft}px` }} <Trash2 className="h-4 w-4" />
> </Button>
<div className="absolute -top-1 left-1/2 -translate-x-1/2 w-3 h-3 bg-accent rounded-full" />
</div> </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> </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> </div>
</div>
</div> </div>
); );
} }
@@ -1,9 +1,9 @@
import { Plus, Trash2, Play } from 'lucide-react'; import { Plus, Trash2, Play } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { apiClient } from '@/lib/api/client';
import { useDeleteSample, useProfileSamples } from '@/lib/hooks/useProfiles'; import { useDeleteSample, useProfileSamples } from '@/lib/hooks/useProfiles';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
import { apiClient } from '@/lib/api/client';
import { SampleUpload } from './SampleUpload'; import { SampleUpload } from './SampleUpload';
interface SampleListProps { interface SampleListProps {
@@ -14,7 +14,7 @@ export function SampleList({ profileId }: SampleListProps) {
const { data: samples, isLoading } = useProfileSamples(profileId); const { data: samples, isLoading } = useProfileSamples(profileId);
const deleteSample = useDeleteSample(); const deleteSample = useDeleteSample();
const [uploadOpen, setUploadOpen] = useState(false); const [uploadOpen, setUploadOpen] = useState(false);
const setAudio = usePlayerStore((state) => state.setAudio); const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
const currentAudioId = usePlayerStore((state) => state.audioId); const currentAudioId = usePlayerStore((state) => state.audioId);
const isPlaying = usePlayerStore((state) => state.isPlaying); const isPlaying = usePlayerStore((state) => state.isPlaying);
@@ -26,7 +26,7 @@ export function SampleList({ profileId }: SampleListProps) {
const handlePlay = (referenceText: string, sampleId: string) => { const handlePlay = (referenceText: string, sampleId: string) => {
const audioUrl = apiClient.getSampleUrl(sampleId); const audioUrl = apiClient.getSampleUrl(sampleId);
setAudio(audioUrl, sampleId, referenceText.substring(0, 50)); setAudioWithAutoPlay(audioUrl, sampleId, null, referenceText.substring(0, 50));
}; };
if (isLoading) { if (isLoading) {
+26 -4
View File
@@ -21,6 +21,8 @@ import type {
StoryItemBatchUpdate, StoryItemBatchUpdate,
StoryItemReorder, StoryItemReorder,
StoryItemMove, StoryItemMove,
StoryItemTrim,
StoryItemSplit,
} from './types'; } from './types';
class ApiClient { class ApiClient {
@@ -406,8 +408,8 @@ class ApiClient {
}); });
} }
async removeStoryItem(storyId: string, generationId: string): Promise<void> { async removeStoryItem(storyId: string, itemId: string): Promise<void> {
await this.request<void>(`/stories/${storyId}/items/${generationId}`, { await this.request<void>(`/stories/${storyId}/items/${itemId}`, {
method: 'DELETE', method: 'DELETE',
}); });
} }
@@ -426,13 +428,33 @@ class ApiClient {
}); });
} }
async moveStoryItem(storyId: string, generationId: string, data: StoryItemMove): Promise<StoryItemDetail> { async moveStoryItem(storyId: string, itemId: string, data: StoryItemMove): Promise<StoryItemDetail> {
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${generationId}/move`, { return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/move`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(data), 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> { async exportStoryAudio(storyId: string): Promise<Blob> {
const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`; const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`;
const response = await fetch(url); const response = await fetch(url);
+11
View File
@@ -144,6 +144,8 @@ export interface StoryItemDetail {
generation_id: string; generation_id: string;
start_time_ms: number; start_time_ms: number;
track: number; track: number;
trim_start_ms: number;
trim_end_ms: number;
created_at: string; created_at: string;
profile_id: string; profile_id: string;
profile_name: string; profile_name: string;
@@ -188,3 +190,12 @@ export interface StoryItemMove {
start_time_ms: number; start_time_ms: number;
track: number; track: number;
} }
export interface StoryItemTrim {
trim_start_ms: number;
trim_end_ms: number;
}
export interface StoryItemSplit {
split_time_ms: number;
}
+2 -2
View File
@@ -28,7 +28,7 @@ interface UseGenerationFormOptions {
export function useGenerationForm(options: UseGenerationFormOptions = {}) { export function useGenerationForm(options: UseGenerationFormOptions = {}) {
const { toast } = useToast(); const { toast } = useToast();
const generation = useGeneration(); const generation = useGeneration();
const setAudio = usePlayerStore((state) => state.setAudio); const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating); const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null); const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null); const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
@@ -97,7 +97,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
}); });
const audioUrl = apiClient.getAudioUrl(result.id); const audioUrl = apiClient.getAudioUrl(result.id);
setAudio(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50)); setAudioWithAutoPlay(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
form.reset(); form.reset();
options.onSuccess?.(result.id); options.onSuccess?.(result.id);
+44 -5
View File
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import type { StoryCreate, StoryItemCreate, StoryItemBatchUpdate, StoryItemReorder, StoryItemMove } from '@/lib/api/types'; import type { StoryCreate, StoryItemCreate, StoryItemBatchUpdate, StoryItemReorder, StoryItemMove, StoryItemTrim, StoryItemSplit } from '@/lib/api/types';
import { isTauri } from '@/lib/tauri'; import { isTauri } from '@/lib/tauri';
export function useStories() { export function useStories() {
@@ -70,8 +70,8 @@ export function useRemoveStoryItem() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ storyId, generationId }: { storyId: string; generationId: string }) => mutationFn: ({ storyId, itemId }: { storyId: string; itemId: string }) =>
apiClient.removeStoryItem(storyId, generationId), apiClient.removeStoryItem(storyId, itemId),
onSuccess: (_, variables) => { onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories'] }); queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] }); queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
@@ -109,8 +109,47 @@ export function useMoveStoryItem() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ storyId, generationId, data }: { storyId: string; generationId: string; data: StoryItemMove }) => mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemMove }) =>
apiClient.moveStoryItem(storyId, generationId, data), 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) => { onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories'] }); queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] }); queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
+36 -22
View File
@@ -5,6 +5,7 @@ import { useStoryStore } from '@/stores/storyStore';
interface ActiveSource { interface ActiveSource {
source: AudioBufferSourceNode; source: AudioBufferSourceNode;
itemId: string;
generationId: string; generationId: string;
startTimeMs: number; startTimeMs: number;
endTimeMs: number; endTimeMs: number;
@@ -26,9 +27,9 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
const audioContextRef = useRef<AudioContext | null>(null); const audioContextRef = useRef<AudioContext | null>(null);
// Master gain for volume control // Master gain for volume control
const masterGainRef = useRef<GainNode | null>(null); const masterGainRef = useRef<GainNode | null>(null);
// Preloaded AudioBuffers by generation_id // Preloaded AudioBuffers by generation_id (audio file is shared between split clips)
const audioBuffersRef = useRef<Map<string, AudioBuffer>>(new Map()); const audioBuffersRef = useRef<Map<string, AudioBuffer>>(new Map());
// Currently playing AudioBufferSourceNodes by generation_id // Currently playing AudioBufferSourceNodes by item.id (unique per clip)
const activeSourcesRef = useRef<Map<string, ActiveSource>>(new Map()); const activeSourcesRef = useRef<Map<string, ActiveSource>>(new Map());
// Animation frame for syncing visual playhead // Animation frame for syncing visual playhead
const animationFrameRef = useRef<number | null>(null); const animationFrameRef = useRef<number | null>(null);
@@ -56,16 +57,16 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
return audioContextRef.current; return audioContextRef.current;
}, []); }, []);
// Stop a source // Stop a source by item id
const stopSource = useCallback((generationId: string) => { const stopSource = useCallback((itemId: string) => {
const activeSource = activeSourcesRef.current.get(generationId); const activeSource = activeSourcesRef.current.get(itemId);
if (activeSource) { if (activeSource) {
try { try {
activeSource.source.stop(); activeSource.source.stop();
} catch { } catch {
// Source may have already stopped // Source may have already stopped
} }
activeSourcesRef.current.delete(generationId); activeSourcesRef.current.delete(itemId);
} }
}, []); }, []);
@@ -123,8 +124,8 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
useEffect(() => { useEffect(() => {
return () => { return () => {
// Stop all sources // Stop all sources
for (const [generationId] of activeSourcesRef.current) { for (const [itemId] of activeSourcesRef.current) {
stopSource(generationId); stopSource(itemId);
} }
activeSourcesRef.current.clear(); activeSourcesRef.current.clear();
@@ -151,7 +152,11 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
(storyTimeMs: number, itemList: StoryItemDetail[]): StoryItemDetail[] => { (storyTimeMs: number, itemList: StoryItemDetail[]): StoryItemDetail[] => {
return itemList.filter((item) => { return itemList.filter((item) => {
const itemStart = item.start_time_ms; const itemStart = item.start_time_ms;
const itemEnd = item.start_time_ms + item.duration * 1000; // 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; return storyTimeMs >= itemStart && storyTimeMs < itemEnd;
}); });
}, },
@@ -185,8 +190,8 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
// Stop all sources // Stop all sources
const stopAllSources = useCallback(() => { const stopAllSources = useCallback(() => {
console.log('[StoryPlayback] Stopping all sources'); console.log('[StoryPlayback] Stopping all sources');
for (const [generationId] of activeSourcesRef.current) { for (const [itemId] of activeSourcesRef.current) {
stopSource(generationId); stopSource(itemId);
} }
activeSourcesRef.current.clear(); activeSourcesRef.current.clear();
}, [stopSource]); }, [stopSource]);
@@ -199,18 +204,18 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
// Find all items that should be playing // Find all items that should be playing
const shouldBePlaying = findActiveItems(storyTimeMs, itemList); const shouldBePlaying = findActiveItems(storyTimeMs, itemList);
const shouldBePlayingIds = new Set(shouldBePlaying.map((item) => item.generation_id)); const shouldBePlayingIds = new Set(shouldBePlaying.map((item) => item.id));
// Stop sources that shouldn't be playing anymore // Stop sources that shouldn't be playing anymore
for (const [generationId] of activeSourcesRef.current) { for (const [itemId] of activeSourcesRef.current) {
if (!shouldBePlayingIds.has(generationId)) { if (!shouldBePlayingIds.has(itemId)) {
stopSource(generationId); stopSource(itemId);
} }
} }
// Schedule new sources for items that should be playing // Schedule new sources for items that should be playing
for (const item of shouldBePlaying) { for (const item of shouldBePlaying) {
if (!activeSourcesRef.current.has(item.generation_id)) { if (!activeSourcesRef.current.has(item.id)) {
const buffer = audioBuffersRef.current.get(item.generation_id); const buffer = audioBuffersRef.current.get(item.generation_id);
if (!buffer) { if (!buffer) {
console.warn('[StoryPlayback] Buffer not loaded for:', item.generation_id); console.warn('[StoryPlayback] Buffer not loaded for:', item.generation_id);
@@ -219,16 +224,24 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
// Calculate when this item should start in AudioContext time // Calculate when this item should start in AudioContext time
const itemStartContextTime = storyTimeToContextTime(item.start_time_ms); const itemStartContextTime = storyTimeToContextTime(item.start_time_ms);
const itemEndStoryTime = item.start_time_ms + item.duration * 1000;
// 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) // Calculate offset into the buffer (if seeking mid-way)
const offsetIntoBuffer = Math.max(0, (storyTimeMs - item.start_time_ms) / 1000); // Offset is relative to the trimmed start of the clip
const duration = item.duration - offsetIntoBuffer; 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 // If the item should have already started, schedule it to start immediately
const startAtContextTime = Math.max(currentContextTime, itemStartContextTime); const startAtContextTime = Math.max(currentContextTime, itemStartContextTime);
console.log('[StoryPlayback] Scheduling source:', { console.log('[StoryPlayback] Scheduling source:', {
itemId: item.id,
generationId: item.generation_id, generationId: item.generation_id,
storyTimeMs, storyTimeMs,
itemStart: item.start_time_ms, itemStart: item.start_time_ms,
@@ -243,20 +256,21 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
const activeSource: ActiveSource = { const activeSource: ActiveSource = {
source, source,
itemId: item.id,
generationId: item.generation_id, generationId: item.generation_id,
startTimeMs: item.start_time_ms, startTimeMs: item.start_time_ms,
endTimeMs: itemEndStoryTime, endTimeMs: itemEndStoryTime,
}; };
activeSourcesRef.current.set(item.generation_id, activeSource); activeSourcesRef.current.set(item.id, activeSource);
// Schedule playback // Schedule playback
source.start(startAtContextTime, offsetIntoBuffer, duration); source.start(startAtContextTime, offsetIntoBuffer, duration);
// Clean up when source ends // Clean up when source ends
source.onended = () => { source.onended = () => {
console.log('[StoryPlayback] Source ended:', item.generation_id); console.log('[StoryPlayback] Source ended:', item.id);
activeSourcesRef.current.delete(item.generation_id); activeSourcesRef.current.delete(item.id);
}; };
} }
} }
+26 -6
View File
@@ -5,6 +5,8 @@ interface StoryPlaybackState {
// Selection // Selection
selectedStoryId: string | null; selectedStoryId: string | null;
setSelectedStoryId: (id: string | null) => void; setSelectedStoryId: (id: string | null) => void;
selectedClipId: string | null;
setSelectedClipId: (id: string | null) => void;
// Track editor UI state // Track editor UI state
trackEditorHeight: number; trackEditorHeight: number;
@@ -26,6 +28,7 @@ interface StoryPlaybackState {
stop: () => void; stop: () => void;
seek: (timeMs: number) => void; seek: (timeMs: number) => void;
setPlaybackTiming: (contextTime: number, storyTime: number) => void; // Set timing anchors for Web Audio API 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; const DEFAULT_TRACK_EDITOR_HEIGHT = 250;
@@ -34,6 +37,8 @@ export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
// Selection // Selection
selectedStoryId: null, selectedStoryId: null,
setSelectedStoryId: (id) => set({ selectedStoryId: id }), setSelectedStoryId: (id) => set({ selectedStoryId: id }),
selectedClipId: null,
setSelectedClipId: (id) => set({ selectedClipId: id }),
// Track editor UI state // Track editor UI state
trackEditorHeight: DEFAULT_TRACK_EDITOR_HEIGHT, trackEditorHeight: DEFAULT_TRACK_EDITOR_HEIGHT,
@@ -53,14 +58,11 @@ export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
// Calculate total duration from items // Calculate total duration from items
const maxEndTimeMs = Math.max( const maxEndTimeMs = Math.max(
...items.map((item) => item.start_time_ms + item.duration * 1000), ...items.map((item) => item.start_time_ms + item.duration * 1000),
0 0,
); );
// Find the minimum start time (first item) // Find the minimum start time (first item)
const minStartTimeMs = Math.min( const minStartTimeMs = Math.min(...items.map((item) => item.start_time_ms), 0);
...items.map((item) => item.start_time_ms),
0
);
// If resuming the same story, keep position; otherwise start at first item // If resuming the same story, keep position; otherwise start at first item
const currentState = get(); const currentState = get();
@@ -70,7 +72,11 @@ export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
console.log('[StoryStore] Play called:', { console.log('[StoryStore] Play called:', {
storyId, storyId,
itemCount: items.length, itemCount: items.length,
items: items.map(i => ({ id: i.generation_id, start: i.start_time_ms, duration: i.duration })), items: items.map((i) => ({
id: i.generation_id,
start: i.start_time_ms,
duration: i.duration,
})),
maxEndTimeMs, maxEndTimeMs,
minStartTimeMs, minStartTimeMs,
startTimeMs, startTimeMs,
@@ -122,4 +128,18 @@ export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
playbackStartStoryTime: storyTime, 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,
});
}
},
})); }));
+2
View File
@@ -1 +1,3 @@
# Backend package # Backend package
__version__ = "0.1.8"
+20
View File
@@ -71,6 +71,8 @@ class StoryItem(Base):
generation_id = Column(String, ForeignKey("generations.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 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) 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) created_at = Column(DateTime, default=datetime.utcnow)
@@ -257,6 +259,24 @@ def _run_migrations(engine):
conn.commit() conn.commit()
print("Added track column to story_items") 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)."""
+166 -31
View File
@@ -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, stories 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,13 +51,24 @@ app.add_middleware(
@app.get("/") @app.get("/")
async def root(): async def root():
"""Root endpoint.""" """Root endpoint."""
return {"message": "voicebox API", "version": "0.1.6"} 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
@@ -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,
) )
@@ -451,6 +473,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 +736,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(
@@ -770,14 +853,14 @@ async def add_story_item(
return item return item
@app.delete("/stories/{story_id}/items/{generation_id}") @app.delete("/stories/{story_id}/items/{item_id}")
async def remove_story_item( async def remove_story_item(
story_id: str, story_id: str,
generation_id: str, item_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Remove a generation from a story.""" """Remove a story item from a story."""
success = await stories.remove_item_from_story(story_id, generation_id, db) success = await stories.remove_item_from_story(story_id, item_id, db)
if not success: if not success:
raise HTTPException(status_code=404, detail="Story item not found") raise HTTPException(status_code=404, detail="Story item not found")
return {"message": "Item removed successfully"} return {"message": "Item removed successfully"}
@@ -809,15 +892,56 @@ async def reorder_story_items(
return items return items
@app.put("/stories/{story_id}/items/{generation_id}/move", response_model=models.StoryItemDetail) @app.put("/stories/{story_id}/items/{item_id}/move", response_model=models.StoryItemDetail)
async def move_story_item( async def move_story_item(
story_id: str, story_id: str,
generation_id: str, item_id: str,
data: models.StoryItemMove, data: models.StoryItemMove,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Move a story item (update position and/or track).""" """Move a story item (update position and/or track)."""
item = await stories.move_story_item(story_id, generation_id, data, db) 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: if item is None:
raise HTTPException(status_code=404, detail="Story item not found") raise HTTPException(status_code=404, detail="Story item not found")
return item return item
@@ -953,7 +1077,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
@@ -1056,10 +1180,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():
@@ -1168,22 +1292,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"])
task_manager.complete_download(request.model_name)
except Exception as e:
task_manager.error_download(request.model_name, str(e))
# Trigger download by loading the model (which will download if not cached) # Start tracking download
# Run in background to avoid blocking task_manager.start_download(request.model_name)
await asyncio.to_thread(config["load_func"])
# Mark download as complete # Start download in background task (don't await)
task_manager.complete_download(request.model_name) asyncio.create_task(download_in_background())
return {"message": f"Model {request.model_name} download started"} # Return immediately - frontend should poll progress endpoint
except Exception as e: return {"message": f"Model {request.model_name} download started"}
# Mark download as failed
task_manager.error_download(request.model_name, str(e))
raise HTTPException(status_code=500, detail=str(e))
@app.delete("/models/{model_name}") @app.delete("/models/{model_name}")
@@ -1191,6 +1315,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 = {
@@ -1243,8 +1368,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
@@ -1355,6 +1480,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():
+14
View File
@@ -118,6 +118,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
@@ -221,6 +222,8 @@ class StoryItemDetail(BaseModel):
generation_id: str generation_id: str
start_time_ms: int start_time_ms: int
track: int = 0 track: int = 0
trim_start_ms: int = 0
trim_end_ms: int = 0
created_at: datetime created_at: datetime
# Generation details # Generation details
profile_id: str profile_id: str
@@ -277,3 +280,14 @@ class StoryItemMove(BaseModel):
"""Request model for moving a story item (position and/or track).""" """Request model for moving a story item (position and/or track)."""
start_time_ms: int = Field(..., ge=0) start_time_ms: int = Field(..., ge=0)
track: int = 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)
+311 -11
View File
@@ -18,6 +18,8 @@ from .models import (
StoryItemCreate, StoryItemCreate,
StoryItemBatchUpdate, StoryItemBatchUpdate,
StoryItemMove, StoryItemMove,
StoryItemTrim,
StoryItemSplit,
) )
from .database import Story as DBStory, StoryItem as DBStoryItem, Generation as DBGeneration, VoiceProfile as DBVoiceProfile from .database import Story as DBStory, StoryItem as DBStoryItem, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
from .utils.audio import load_audio, save_audio from .utils.audio import load_audio, save_audio
@@ -129,6 +131,8 @@ async def get_story(
generation_id=item.generation_id, generation_id=item.generation_id,
start_time_ms=item.start_time_ms, start_time_ms=item.start_time_ms,
track=item.track, 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, created_at=item.created_at,
profile_id=generation.profile_id, profile_id=generation.profile_id,
profile_name=profile_name, profile_name=profile_name,
@@ -252,6 +256,8 @@ async def add_item_to_story(
generation_id=existing.generation_id, generation_id=existing.generation_id,
start_time_ms=existing.start_time_ms, start_time_ms=existing.start_time_ms,
track=existing.track, 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, created_at=existing.created_at,
profile_id=generation.profile_id, profile_id=generation.profile_id,
profile_name=profile.name if profile else "Unknown", profile_name=profile.name if profile else "Unknown",
@@ -321,6 +327,8 @@ async def add_item_to_story(
generation_id=item.generation_id, generation_id=item.generation_id,
start_time_ms=item.start_time_ms, start_time_ms=item.start_time_ms,
track=item.track, 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, created_at=item.created_at,
profile_id=generation.profile_id, profile_id=generation.profile_id,
profile_name=profile.name if profile else "Unknown", profile_name=profile.name if profile else "Unknown",
@@ -336,7 +344,7 @@ async def add_item_to_story(
async def move_story_item( async def move_story_item(
story_id: str, story_id: str,
generation_id: str, item_id: str,
data: StoryItemMove, data: StoryItemMove,
db: Session, db: Session,
) -> Optional[StoryItemDetail]: ) -> Optional[StoryItemDetail]:
@@ -345,7 +353,7 @@ async def move_story_item(
Args: Args:
story_id: Story ID story_id: Story ID
generation_id: Generation ID of the item to move item_id: Story item ID
data: New position and track data data: New position and track data
db: Database session db: Database session
@@ -354,14 +362,14 @@ async def move_story_item(
""" """
# Get the item # Get the item
item = db.query(DBStoryItem).filter_by( item = db.query(DBStoryItem).filter_by(
id=item_id,
story_id=story_id, story_id=story_id,
generation_id=generation_id
).first() ).first()
if not item: if not item:
return None return None
# Get the generation # Get the generation
generation = db.query(DBGeneration).filter_by(id=generation_id).first() generation = db.query(DBGeneration).filter_by(id=item.generation_id).first()
if not generation: if not generation:
return None return None
@@ -386,6 +394,8 @@ async def move_story_item(
generation_id=item.generation_id, generation_id=item.generation_id,
start_time_ms=item.start_time_ms, start_time_ms=item.start_time_ms,
track=item.track, 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, created_at=item.created_at,
profile_id=generation.profile_id, profile_id=generation.profile_id,
profile_name=profile.name if profile else "Unknown", profile_name=profile.name if profile else "Unknown",
@@ -401,23 +411,23 @@ async def move_story_item(
async def remove_item_from_story( async def remove_item_from_story(
story_id: str, story_id: str,
generation_id: str, item_id: str,
db: Session, db: Session,
) -> bool: ) -> bool:
""" """
Remove a generation from a story. Remove a story item from a story.
Args: Args:
story_id: Story ID story_id: Story ID
generation_id: Generation ID to remove item_id: Story item ID to remove
db: Database session db: Database session
Returns: Returns:
True if removed, False if not found True if removed, False if not found
""" """
item = db.query(DBStoryItem).filter_by( item = db.query(DBStoryItem).filter_by(
id=item_id,
story_id=story_id, story_id=story_id,
generation_id=generation_id
).first() ).first()
if not item: if not item:
return False return False
@@ -434,6 +444,277 @@ async def remove_item_from_story(
return True 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( async def update_story_item_times(
story_id: str, story_id: str,
data: StoryItemBatchUpdate, data: StoryItemBatchUpdate,
@@ -538,6 +819,8 @@ async def reorder_story_items(
generation_id=item.generation_id, generation_id=item.generation_id,
start_time_ms=item.start_time_ms, start_time_ms=item.start_time_ms,
track=item.track, 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, created_at=item.created_at,
profile_id=generation.profile_id, profile_id=generation.profile_id,
profile_name=profile_name, profile_name=profile_name,
@@ -602,14 +885,31 @@ async def export_story_audio(
audio, sr = load_audio(str(audio_path), sample_rate=sample_rate) audio, sr = load_audio(str(audio_path), sample_rate=sample_rate)
sample_rate = sr # Use actual sample rate from first file 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 # Store audio with its timecode info
start_time_ms = item.start_time_ms start_time_ms = item.start_time_ms
duration_ms = int(generation.duration * 1000)
audio_data.append({ audio_data.append({
'audio': audio, 'audio': trimmed_audio,
'start_time_ms': start_time_ms, 'start_time_ms': start_time_ms,
'duration_ms': duration_ms, 'duration_ms': effective_duration_ms,
}) })
except Exception: except Exception:
# Skip files that can't be loaded # Skip files that can't be loaded
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@voicebox/landing", "name": "@voicebox/landing",
"version": "0.1.6", "version": "0.1.8",
"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

+38 -3
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{ {
"name": "voicebox", "name": "voicebox",
"version": "0.1.6", "version": "0.1.8",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"app", "app",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@voicebox/tauri", "name": "@voicebox/tauri",
"private": true, "private": true,
"version": "0.1.6", "version": "0.1.8",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+219 -6
View File
@@ -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.5" 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"
+2 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "voicebox" name = "voicebox"
version = "0.1.6" version = "0.1.8"
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",
+153 -10
View File
@@ -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");
}
} }
println!("stop_server: Process group kill completed"); #[cfg(unix)]
{
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 -1
View File
@@ -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.6", "version": "0.1.8",
"identifier": "sh.voicebox.app", "identifier": "sh.voicebox.app",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@voicebox/web", "name": "@voicebox/web",
"private": true, "private": true,
"version": "0.1.6", "version": "0.1.8",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",