diff --git a/app/src/stores/generationStore.ts b/app/src/stores/generationStore.ts
index c0d63383..edf715d1 100644
--- a/app/src/stores/generationStore.ts
+++ b/app/src/stores/generationStore.ts
@@ -1,15 +1,58 @@
import { create } from 'zustand';
interface GenerationState {
+ /** IDs of generations currently in progress */
+ pendingGenerationIds: Set;
+ /** Whether any generation is in progress (derived from pendingGenerationIds) */
isGenerating: boolean;
- activeGenerationId: string | null;
- setIsGenerating: (generating: boolean) => void;
+ /** Map of generationId → storyId for deferred story additions */
+ pendingStoryAdds: Map;
+ addPendingGeneration: (id: string) => void;
+ removePendingGeneration: (id: string) => void;
+ addPendingStoryAdd: (generationId: string, storyId: string) => void;
+ removePendingStoryAdd: (generationId: string) => string | undefined;
setActiveGenerationId: (id: string | null) => void;
+ activeGenerationId: string | null;
}
-export const useGenerationStore = create((set) => ({
+export const useGenerationStore = create((set, get) => ({
+ pendingGenerationIds: new Set(),
isGenerating: false,
activeGenerationId: null,
- setIsGenerating: (generating) => set({ isGenerating: generating }),
+ pendingStoryAdds: new Map(),
+
+ addPendingGeneration: (id) =>
+ set((state) => {
+ const next = new Set(state.pendingGenerationIds);
+ next.add(id);
+ return { pendingGenerationIds: next, isGenerating: true };
+ }),
+
+ removePendingGeneration: (id) =>
+ set((state) => {
+ const next = new Set(state.pendingGenerationIds);
+ next.delete(id);
+ return { pendingGenerationIds: next, isGenerating: next.size > 0 };
+ }),
+
+ addPendingStoryAdd: (generationId, storyId) =>
+ set((state) => {
+ const next = new Map(state.pendingStoryAdds);
+ next.set(generationId, storyId);
+ return { pendingStoryAdds: next };
+ }),
+
+ removePendingStoryAdd: (generationId) => {
+ const storyId = get().pendingStoryAdds.get(generationId);
+ if (storyId) {
+ set((state) => {
+ const next = new Map(state.pendingStoryAdds);
+ next.delete(generationId);
+ return { pendingStoryAdds: next };
+ });
+ }
+ return storyId;
+ },
+
setActiveGenerationId: (id) => set({ activeGenerationId: id }),
}));
diff --git a/app/src/stores/serverStore.ts b/app/src/stores/serverStore.ts
index 586e1e8c..8f983049 100644
--- a/app/src/stores/serverStore.ts
+++ b/app/src/stores/serverStore.ts
@@ -23,6 +23,9 @@ interface ServerStore {
normalizeAudio: boolean;
setNormalizeAudio: (value: boolean) => void;
+ autoplayOnGenerate: boolean;
+ setAutoplayOnGenerate: (value: boolean) => void;
+
customModelsDir: string | null;
setCustomModelsDir: (dir: string | null) => void;
}
@@ -51,6 +54,9 @@ export const useServerStore = create()(
normalizeAudio: true,
setNormalizeAudio: (value) => set({ normalizeAudio: value }),
+ autoplayOnGenerate: true,
+ setAutoplayOnGenerate: (value) => set({ autoplayOnGenerate: value }),
+
customModelsDir: null,
setCustomModelsDir: (dir) => set({ customModelsDir: dir }),
}),
diff --git a/backend/database.py b/backend/database.py
index 3b9c51ee..d4bfed22 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -45,10 +45,14 @@ class Generation(Base):
profile_id = Column(String, ForeignKey("profiles.id"), nullable=False)
text = Column(Text, nullable=False)
language = Column(String, default="en")
- audio_path = Column(String, nullable=False)
- duration = Column(Float, nullable=False)
+ audio_path = Column(String, nullable=True)
+ duration = Column(Float, nullable=True)
seed = Column(Integer)
instruct = Column(Text)
+ engine = Column(String, default="qwen")
+ model_size = Column(String, nullable=True)
+ status = Column(String, default="completed") # generating, completed, failed
+ error = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
@@ -288,6 +292,36 @@ def _run_migrations(engine):
conn.commit()
print("Added avatar_path column to profiles")
+ # Migration: Add status and error columns to generations table
+ if 'generations' in inspector.get_table_names():
+ columns = {col['name'] for col in inspector.get_columns('generations')}
+ if 'status' not in columns:
+ print("Migrating generations: adding status column")
+ with engine.connect() as conn:
+ conn.execute(text("ALTER TABLE generations ADD COLUMN status VARCHAR DEFAULT 'completed'"))
+ conn.commit()
+ print("Added status column to generations")
+ if 'error' not in columns:
+ print("Migrating generations: adding error column")
+ with engine.connect() as conn:
+ conn.execute(text("ALTER TABLE generations ADD COLUMN error TEXT"))
+ conn.commit()
+ print("Added error column to generations")
+ if 'engine' not in columns:
+ print("Migrating generations: adding engine column")
+ with engine.connect() as conn:
+ conn.execute(text("ALTER TABLE generations ADD COLUMN engine VARCHAR DEFAULT 'qwen'"))
+ conn.commit()
+ print("Added engine column to generations")
+ # Re-read columns after engine migration (variable name shadows outer `engine`)
+ columns = {col['name'] for col in inspector.get_columns('generations')}
+ if 'model_size' not in columns:
+ print("Migrating generations: adding model_size column")
+ with engine.connect() as conn:
+ conn.execute(text("ALTER TABLE generations ADD COLUMN model_size VARCHAR"))
+ conn.commit()
+ print("Added model_size column to generations")
+
def get_db():
"""Get database session (generator for dependency injection)."""
diff --git a/backend/history.py b/backend/history.py
index 64834d30..b8026b09 100644
--- a/backend/history.py
+++ b/backend/history.py
@@ -29,6 +29,10 @@ async def create_generation(
seed: Optional[int],
db: Session,
instruct: Optional[str] = None,
+ generation_id: Optional[str] = None,
+ status: str = "completed",
+ engine: Optional[str] = "qwen",
+ model_size: Optional[str] = None,
) -> GenerationResponse:
"""
Create a new generation history entry.
@@ -42,12 +46,16 @@ async def create_generation(
seed: Random seed used (if any)
db: Database session
instruct: Natural language instruction used (if any)
+ generation_id: Pre-assigned ID (for async generation flow)
+ status: Generation status (generating, completed, failed)
+ engine: TTS engine used (qwen, luxtts, chatterbox, chatterbox_turbo)
+ model_size: Model size variant (1.7B, 0.6B) — only relevant for qwen
Returns:
Created generation entry
"""
db_generation = DBGeneration(
- id=str(uuid.uuid4()),
+ id=generation_id or str(uuid.uuid4()),
profile_id=profile_id,
text=text,
language=language,
@@ -55,6 +63,9 @@ async def create_generation(
duration=duration,
seed=seed,
instruct=instruct,
+ engine=engine,
+ model_size=model_size,
+ status=status,
created_at=datetime.utcnow(),
)
@@ -65,6 +76,32 @@ async def create_generation(
return GenerationResponse.model_validate(db_generation)
+async def update_generation_status(
+ generation_id: str,
+ status: str,
+ db: Session,
+ audio_path: Optional[str] = None,
+ duration: Optional[float] = None,
+ error: Optional[str] = None,
+) -> Optional[GenerationResponse]:
+ """Update the status of a generation (used by async generation flow)."""
+ generation = db.query(DBGeneration).filter_by(id=generation_id).first()
+ if not generation:
+ return None
+
+ generation.status = status
+ if audio_path is not None:
+ generation.audio_path = audio_path
+ if duration is not None:
+ generation.duration = duration
+ if error is not None:
+ generation.error = error
+
+ db.commit()
+ db.refresh(generation)
+ return GenerationResponse.model_validate(generation)
+
+
async def get_generation(
generation_id: str,
db: Session,
@@ -143,6 +180,10 @@ async def list_generations(
duration=generation.duration,
seed=generation.seed,
instruct=generation.instruct,
+ engine=generation.engine or "qwen",
+ model_size=generation.model_size,
+ status=generation.status or "completed",
+ error=generation.error,
created_at=generation.created_at,
))
diff --git a/backend/main.py b/backend/main.py
index 9b3aa334..487d35ab 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -62,6 +62,9 @@ def _safe_content_disposition(disposition_type: str, filename: str) -> str:
# Keep references to fire-and-forget background tasks to prevent GC
_background_tasks: set = set()
+# Generation queue — serializes TTS inference to avoid GPU contention
+_generation_queue: asyncio.Queue = None # type: ignore # initialized at startup
+
def _create_background_task(coro) -> asyncio.Task:
"""Create a background task and prevent it from being garbage collected."""
@@ -71,6 +74,24 @@ def _create_background_task(coro) -> asyncio.Task:
return task
+async def _generation_worker():
+ """Worker that processes generation tasks one at a time."""
+ while True:
+ coro = await _generation_queue.get()
+ try:
+ await coro
+ except Exception:
+ import traceback
+ traceback.print_exc()
+ finally:
+ _generation_queue.task_done()
+
+
+def _enqueue_generation(coro):
+ """Add a generation coroutine to the serial queue."""
+ _generation_queue.put_nowait(coro)
+
+
app = FastAPI(
title="voicebox API",
description="Production-quality Qwen3-TTS voice cloning API",
@@ -695,214 +716,255 @@ async def generate_speech(
data: models.GenerationRequest,
db: Session = Depends(get_db),
):
- """Generate speech from text using a voice profile."""
+ """Generate speech from text using a voice profile.
+
+ Creates a history entry immediately with status='generating' and kicks off
+ TTS in the background. The frontend can poll or use SSE to detect completion.
+ """
task_manager = get_task_manager()
generation_id = str(uuid.uuid4())
-
- try:
- # Start tracking generation
- task_manager.start_generation(
- task_id=generation_id,
- profile_id=data.profile_id,
- text=data.text,
- )
-
- # Get profile
- profile = await profiles.get_profile(data.profile_id, db)
- if not profile:
- raise HTTPException(status_code=404, detail="Profile not found")
-
- # Generate audio
- from .backends import get_tts_backend_for_engine
- engine = data.engine or "qwen"
- tts_model = get_tts_backend_for_engine(engine)
+ # Validate profile exists before creating the record
+ profile = await profiles.get_profile(data.profile_id, db)
+ if not profile:
+ raise HTTPException(status_code=404, detail="Profile not found")
- # Resolve model size (only relevant for Qwen engine)
- model_size = data.model_size or "1.7B"
+ from .backends import get_tts_backend_for_engine
+ engine = data.engine or "qwen"
+ tts_model = get_tts_backend_for_engine(engine)
+ model_size = data.model_size or "1.7B"
+
+ # Create the history entry immediately with status="generating"
+ generation = await history.create_generation(
+ profile_id=data.profile_id,
+ text=data.text,
+ language=data.language,
+ audio_path="",
+ duration=0,
+ seed=data.seed,
+ db=db,
+ instruct=data.instruct,
+ generation_id=generation_id,
+ status="generating",
+ engine=engine,
+ model_size=model_size if engine == "qwen" else None,
+ )
- # Check if model needs to be downloaded first
- if engine == "qwen":
- if not tts_model._is_model_cached(model_size):
- model_name = f"qwen-tts-{model_size}"
+ # Track in task manager
+ task_manager.start_generation(
+ task_id=generation_id,
+ profile_id=data.profile_id,
+ text=data.text,
+ )
- 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)
- _create_background_task(download_model_background())
-
- 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,
- },
- )
+ # Kick off TTS in background
+ async def _run_generation():
+ bg_db = next(get_db())
+ try:
+ # Load model
+ if engine == "qwen":
+ await tts_model.load_model_async(model_size)
+ else:
+ await tts_model.load_model()
+
+ # Create voice prompt
+ voice_prompt = await profiles.create_voice_prompt_for_profile(
+ data.profile_id,
+ bg_db,
+ use_cache=True,
+ engine=engine,
+ )
- # Load (or switch to) the requested model
- await tts_model.load_model_async(model_size)
- elif engine == "luxtts":
- if not tts_model._is_model_cached():
- model_name = "luxtts"
+ from .utils.chunked_tts import generate_chunked
+
+ trim_fn = None
+ if engine in ("chatterbox", "chatterbox_turbo"):
+ from .utils.audio import trim_tts_output
+ trim_fn = trim_tts_output
+
+ audio, sample_rate = await generate_chunked(
+ tts_model,
+ data.text,
+ voice_prompt,
+ language=data.language,
+ seed=data.seed,
+ instruct=data.instruct,
+ max_chunk_chars=data.max_chunk_chars,
+ crossfade_ms=data.crossfade_ms,
+ trim_fn=trim_fn,
+ )
- async def download_luxtts_background():
- try:
- await tts_model.load_model()
- except Exception as e:
- task_manager.error_download(model_name, str(e))
-
- task_manager.start_download(model_name)
- _create_background_task(download_luxtts_background())
-
- raise HTTPException(
- status_code=202,
- detail={
- "message": "LuxTTS model is being downloaded. Please wait and try again.",
- "model_name": model_name,
- "downloading": True,
- },
- )
+ if data.normalize:
+ from .utils.audio import normalize_audio
+ audio = normalize_audio(audio)
- await tts_model.load_model()
- elif engine == "chatterbox":
- if not tts_model._is_model_cached():
- model_name = "chatterbox-tts"
+ duration = len(audio) / sample_rate
+ audio_path = config.get_generations_dir() / f"{generation_id}.wav"
- async def download_chatterbox_background():
- try:
- await tts_model.load_model()
- except Exception as e:
- task_manager.error_download(model_name, str(e))
-
- task_manager.start_download(model_name)
- asyncio.create_task(download_chatterbox_background())
-
- raise HTTPException(
- status_code=202,
- detail={
- "message": "Chatterbox model is being downloaded. Please wait and try again.",
- "model_name": model_name,
- "downloading": True,
- },
- )
+ from .utils.audio import save_audio
+ save_audio(audio, str(audio_path), sample_rate)
- await tts_model.load_model()
- elif engine == "chatterbox_turbo":
- if not tts_model._is_model_cached():
- model_name = "chatterbox-turbo"
+ # Update the record to completed
+ await history.update_generation_status(
+ generation_id=generation_id,
+ status="completed",
+ db=bg_db,
+ audio_path=str(audio_path),
+ duration=duration,
+ )
- async def download_chatterbox_turbo_background():
- try:
- await tts_model.load_model()
- except Exception as e:
- task_manager.error_download(model_name, str(e))
-
- task_manager.start_download(model_name)
- asyncio.create_task(download_chatterbox_turbo_background())
-
- raise HTTPException(
- status_code=202,
- detail={
- "message": "Chatterbox Turbo model is being downloaded. Please wait and try again.",
- "model_name": model_name,
- "downloading": True,
- },
- )
+ except Exception as e:
+ import traceback
+ traceback.print_exc()
+ await history.update_generation_status(
+ generation_id=generation_id,
+ status="failed",
+ db=bg_db,
+ error=str(e),
+ )
+ finally:
+ task_manager.complete_generation(generation_id)
+ bg_db.close()
- await tts_model.load_model()
+ _enqueue_generation(_run_generation())
- # Create voice prompt from profile
- voice_prompt = await profiles.create_voice_prompt_for_profile(
- data.profile_id,
- db,
- use_cache=True,
- engine=engine,
- )
+ return generation
- from .utils.chunked_tts import generate_chunked
-
- # Resolve per-chunk trim function for engines that need it
- trim_fn = None
- if engine in ("chatterbox", "chatterbox_turbo"):
- from .utils.audio import trim_tts_output
- trim_fn = trim_tts_output
-
- audio, sample_rate = await generate_chunked(
- tts_model,
- data.text,
- voice_prompt,
- language=data.language,
- seed=data.seed,
- instruct=data.instruct,
- max_chunk_chars=data.max_chunk_chars,
- crossfade_ms=data.crossfade_ms,
- trim_fn=trim_fn,
- )
- if data.normalize:
- from .utils.audio import normalize_audio
- audio = normalize_audio(audio)
+@app.post("/generate/{generation_id}/retry", response_model=models.GenerationResponse)
+async def retry_generation(generation_id: str, db: Session = Depends(get_db)):
+ """Retry a failed generation using the same parameters."""
+ gen = db.query(DBGeneration).filter_by(id=generation_id).first()
+ if not gen:
+ raise HTTPException(status_code=404, detail="Generation not found")
- # Calculate duration
- duration = len(audio) / sample_rate
+ if (gen.status or "completed") != "failed":
+ raise HTTPException(status_code=400, detail="Only failed generations can be retried")
- # Save audio
- audio_path = config.get_generations_dir() / f"{generation_id}.wav"
+ # Reset the record to generating
+ gen.status = "generating"
+ gen.error = None
+ gen.audio_path = ""
+ gen.duration = 0
+ db.commit()
+ db.refresh(gen)
- from .utils.audio import save_audio
- import errno
+ task_manager = get_task_manager()
+ task_manager.start_generation(
+ task_id=generation_id,
+ profile_id=gen.profile_id,
+ text=gen.text,
+ )
+
+ # Resolve engine/model from stored values
+ retry_engine = gen.engine or "qwen"
+ retry_model_size = gen.model_size or "1.7B"
+ from .backends import get_tts_backend_for_engine
+ tts_model = get_tts_backend_for_engine(retry_engine)
+
+ async def _run_retry():
+ bg_db = next(get_db())
try:
+ if retry_engine == "qwen":
+ await tts_model.load_model_async(retry_model_size)
+ else:
+ await tts_model.load_model()
+
+ voice_prompt = await profiles.create_voice_prompt_for_profile(
+ gen.profile_id,
+ bg_db,
+ use_cache=True,
+ engine=retry_engine,
+ )
+
+ from .utils.chunked_tts import generate_chunked
+
+ trim_fn = None
+ if retry_engine in ("chatterbox", "chatterbox_turbo"):
+ from .utils.audio import trim_tts_output
+ trim_fn = trim_tts_output
+
+ audio, sample_rate = await generate_chunked(
+ tts_model,
+ gen.text,
+ voice_prompt,
+ language=gen.language,
+ seed=gen.seed,
+ instruct=gen.instruct,
+ trim_fn=trim_fn,
+ )
+
+ duration = len(audio) / sample_rate
+ audio_path = config.get_generations_dir() / f"{generation_id}.wav"
+
+ from .utils.audio import save_audio
save_audio(audio, str(audio_path), sample_rate)
- except BrokenPipeError:
- raise HTTPException(
- status_code=500,
- detail="Audio save failed: broken pipe (the output stream was closed unexpectedly)",
+
+ await history.update_generation_status(
+ generation_id=generation_id,
+ status="completed",
+ db=bg_db,
+ audio_path=str(audio_path),
+ duration=duration,
)
- except OSError as save_err:
- err_no = getattr(save_err, "errno", None) or (
- getattr(save_err.__cause__, "errno", None)
- if save_err.__cause__
- else None
+ except Exception as e:
+ import traceback
+ traceback.print_exc()
+ await history.update_generation_status(
+ generation_id=generation_id,
+ status="failed",
+ db=bg_db,
+ error=str(e),
)
- if err_no == errno.ENOENT:
- msg = f"Audio save failed: directory not found — {audio_path.parent}"
- elif err_no == errno.EACCES:
- msg = f"Audio save failed: permission denied — {audio_path.parent}"
- elif err_no == errno.ENOSPC:
- msg = "Audio save failed: no disk space remaining"
- else:
- msg = f"Audio save failed: {save_err}"
- raise HTTPException(status_code=500, detail=msg)
-
- # Create history entry
- generation = await history.create_generation(
- profile_id=data.profile_id,
- text=data.text,
- language=data.language,
- audio_path=str(audio_path),
- duration=duration,
- seed=data.seed,
- db=db,
- instruct=data.instruct,
- )
-
- # Mark generation as complete
- task_manager.complete_generation(generation_id)
-
- return generation
-
- except ValueError as e:
- task_manager.complete_generation(generation_id)
- raise HTTPException(status_code=400, detail=str(e))
- except Exception as e:
- task_manager.complete_generation(generation_id)
- raise HTTPException(status_code=500, detail=str(e))
+ finally:
+ task_manager.complete_generation(generation_id)
+ bg_db.close()
+
+ _enqueue_generation(_run_retry())
+
+ return models.GenerationResponse.model_validate(gen)
+
+
+@app.get("/generate/{generation_id}/status")
+async def get_generation_status(generation_id: str, db: Session = Depends(get_db)):
+ """SSE endpoint that streams generation status updates.
+
+ Polls the DB every second and yields the current status. Closes when
+ the generation reaches 'completed' or 'failed'.
+ """
+ import json
+
+ async def event_stream():
+ while True:
+ db.expire_all()
+ gen = db.query(DBGeneration).filter_by(id=generation_id).first()
+ if not gen:
+ yield f"data: {json.dumps({'status': 'not_found', 'id': generation_id})}\n\n"
+ return
+
+ payload = {
+ "id": gen.id,
+ "status": gen.status or "completed",
+ "duration": gen.duration,
+ "error": gen.error,
+ }
+ yield f"data: {json.dumps(payload)}\n\n"
+
+ if (gen.status or "completed") in ("completed", "failed"):
+ return
+
+ await asyncio.sleep(1)
+
+ return StreamingResponse(
+ event_stream(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ },
+ )
@app.post("/generate/stream")
@@ -2480,9 +2542,29 @@ def _get_gpu_status() -> str:
@app.on_event("startup")
async def startup_event():
"""Run on application startup."""
+ global _generation_queue
print("voicebox API starting up...")
database.init_db()
print(f"Database initialized at {database._db_path}")
+
+ # Start the serial generation worker
+ _generation_queue = asyncio.Queue()
+ _create_background_task(_generation_worker())
+
+ # Mark any stale "generating" records as failed — these are leftovers
+ # from a previous process that was killed mid-generation
+ try:
+ from sqlalchemy import text as sa_text
+ db = next(get_db())
+ result = db.execute(
+ sa_text("UPDATE generations SET status = 'failed', error = 'Server was shut down during generation' WHERE status = 'generating'")
+ )
+ if result.rowcount > 0:
+ print(f"Marked {result.rowcount} stale generation(s) as failed")
+ db.commit()
+ db.close()
+ except Exception as e:
+ print(f"Warning: Could not clean up stale generations: {e}")
backend_type = get_backend_type()
print(f"Backend: {backend_type.upper()}")
print(f"GPU available: {_get_gpu_status()}")
diff --git a/backend/models.py b/backend/models.py
index 8f9dbf10..c62db310 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -69,10 +69,14 @@ class GenerationResponse(BaseModel):
profile_id: str
text: str
language: str
- audio_path: str
- duration: float
- seed: Optional[int]
- instruct: Optional[str]
+ audio_path: Optional[str] = None
+ duration: Optional[float] = None
+ seed: Optional[int] = None
+ instruct: Optional[str] = None
+ engine: Optional[str] = "qwen"
+ model_size: Optional[str] = None
+ status: str = "completed"
+ error: Optional[str] = None
created_at: datetime
class Config:
@@ -94,10 +98,14 @@ class HistoryResponse(BaseModel):
profile_name: str
text: str
language: str
- audio_path: str
- duration: float
- seed: Optional[int]
- instruct: Optional[str]
+ audio_path: Optional[str] = None
+ duration: Optional[float] = None
+ seed: Optional[int] = None
+ instruct: Optional[str] = None
+ engine: Optional[str] = "qwen"
+ model_size: Optional[str] = None
+ status: str = "completed"
+ error: Optional[str] = None
created_at: datetime
class Config:
diff --git a/backend/stories.py b/backend/stories.py
index 2a2f5abb..f63710c7 100644
--- a/backend/stories.py
+++ b/backend/stories.py
@@ -270,11 +270,14 @@ async def add_item_to_story(
generation_created_at=generation.created_at,
)
+ # Get track from data or default to 0
+ track = data.track if data.track is not None else 0
+
# Calculate start_time_ms if not provided
if data.start_time_ms is not None:
start_time_ms = data.start_time_ms
else:
- # Find the maximum end time (start_time_ms + duration_ms) of existing items
+ # Find the maximum end time on the target track only
existing_items = db.query(
DBStoryItem,
DBGeneration
@@ -282,11 +285,11 @@ async def add_item_to_story(
DBGeneration,
DBStoryItem.generation_id == DBGeneration.id
).filter(
- DBStoryItem.story_id == story_id
+ DBStoryItem.story_id == story_id,
+ DBStoryItem.track == track,
).all()
if not existing_items:
- # First item starts at 0
start_time_ms = 0
else:
max_end_time_ms = 0
@@ -297,9 +300,6 @@ async def add_item_to_story(
# Add 200ms gap after the last item
start_time_ms = max_end_time_ms + 200
- # Get track from data or default to 0
- track = data.track if data.track is not None else 0
-
# Create item
item = DBStoryItem(
id=str(uuid.uuid4()),
diff --git a/bun.lock b/bun.lock
index d271b5c6..b507da48 100644
--- a/bun.lock
+++ b/bun.lock
@@ -4,6 +4,10 @@
"workspaces": {
"": {
"name": "voicebox",
+ "dependencies": {
+ "loaders.css": "^0.1.2",
+ "react-loaders": "^3.0.1",
+ },
"devDependencies": {
"@biomejs/biome": "2.3.12",
"@types/node": "^20.0.0",
@@ -678,6 +682,8 @@
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
+ "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
+
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@@ -874,6 +880,8 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
+ "loaders.css": ["loaders.css@0.1.2", "", {}, "sha512-Rhowlq24ey1VOeor+3wYOt9+MjaxBOJm1u4KlQgNC3+0xJ0LS4wq4iG57D/BPzvuD/7HHDGQOWJ+81oR2EI9bQ=="],
+
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
@@ -960,6 +968,8 @@
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
+ "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
+
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
@@ -970,6 +980,10 @@
"react-hook-form": ["react-hook-form@7.71.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w=="],
+ "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
+
+ "react-loaders": ["react-loaders@3.0.1", "", { "dependencies": { "classnames": "^2.2.3" }, "peerDependencies": { "prop-types": ">=15.6.0", "react": ">=15" } }, "sha512-4igMNqs9Fb3d4Z+0UHIGQNJsw/37gX0nUO8QxupnEKRn1dtyYC1LGwk5GuaoDciMQCQc/MmPwb4Fn6ZfdoX1FQ=="],
+
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
diff --git a/package.json b/package.json
index f6af4cbd..d8acb287 100644
--- a/package.json
+++ b/package.json
@@ -40,5 +40,9 @@
"engines": {
"bun": ">=1.0.0"
},
- "packageManager": "bun@1.3.8"
+ "packageManager": "bun@1.3.8",
+ "dependencies": {
+ "loaders.css": "^0.1.2",
+ "react-loaders": "^3.0.1"
+ }
}
diff --git a/tauri/src-tauri/gen/Assets.car b/tauri/src-tauri/gen/Assets.car
index 8065a50c..92c779ac 100644
Binary files a/tauri/src-tauri/gen/Assets.car and b/tauri/src-tauri/gen/Assets.car differ