mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-16 21:30:39 -07:00
feat(stories): import external audio into the timeline (drag-drop + picker)
You can now drop a music file onto the story content area or pick one through the new "Import audio" button in the add-clip popover. Both call POST /generate/import which writes the file to data/generations/<id>.<ext>, probes duration via librosa, and inserts a Generation row pointing at a singleton "Imported Audio" profile (created lazily on first import). The existing addStoryItem flow takes over from there — the timeline doesn't care that the row didn't come out of TTS. Engine field on the row is "import"; it's surfaced on StoryItemDetail so the chat list shows a music icon instead of the (missing) profile avatar and both the dropdown and the track-editor toolbar hide the Regenerate action — there's nothing to regenerate. Accepted formats: wav/mp3/flac/ogg/m4a/aac/webm, capped at 200 MB. Translation keys added across en/ja/zh-CN/zh-TW.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { GripVertical, Mic, MoreHorizontal, Play, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import { GripVertical, Mic, MoreHorizontal, Music, Play, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -85,7 +85,9 @@ export function StoryChatItem({
|
||||
{/* Voice Avatar */}
|
||||
<div className="shrink-0">
|
||||
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center overflow-hidden">
|
||||
{!avatarError ? (
|
||||
{item.engine === 'import' ? (
|
||||
<Music className="h-5 w-5 text-muted-foreground" />
|
||||
) : !avatarError ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={`${item.profile_name} avatar`}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from '@dnd-kit/sortable';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Download, Plus } from 'lucide-react';
|
||||
import { Download, Music, Plus, Upload } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Loader from 'react-loaders';
|
||||
@@ -47,8 +47,12 @@ export function StoryContent() {
|
||||
const addStoryItem = useAddStoryItem();
|
||||
const { toast } = useToast();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const importInputRef = useRef<HTMLInputElement>(null);
|
||||
const pendingCount = useGenerationStore((s) => s.pendingGenerationIds.size);
|
||||
const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration);
|
||||
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const dragDepthRef = useRef(0);
|
||||
|
||||
// Add generation popover state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -229,6 +233,33 @@ export function StoryContent() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleImportAudio = async (file: File) => {
|
||||
if (!story) return;
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const generation = await apiClient.importAudio(file);
|
||||
await addStoryItem.mutateAsync({
|
||||
storyId: story.id,
|
||||
data: { generation_id: generation.id },
|
||||
});
|
||||
setIsAddOpen(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('storyContent.toast.importFailed'),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportFiles = async (files: FileList | File[]) => {
|
||||
for (const file of Array.from(files)) {
|
||||
await handleImportAudio(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddGeneration = (generationId: string) => {
|
||||
if (!story) return;
|
||||
|
||||
@@ -284,7 +315,49 @@ export function StoryContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 relative overflow-hidden">
|
||||
<div
|
||||
className="flex flex-col h-full min-h-0 relative overflow-hidden"
|
||||
onDragEnter={(e) => {
|
||||
if (!e.dataTransfer?.types.includes('Files')) return;
|
||||
e.preventDefault();
|
||||
dragDepthRef.current += 1;
|
||||
setIsDraggingFile(true);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer?.types.includes('Files')) e.preventDefault();
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!e.dataTransfer?.types.includes('Files')) return;
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) setIsDraggingFile(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!e.dataTransfer?.files?.length) return;
|
||||
e.preventDefault();
|
||||
dragDepthRef.current = 0;
|
||||
setIsDraggingFile(false);
|
||||
handleImportFiles(e.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept="audio/*,.wav,.mp3,.flac,.ogg,.m4a,.aac,.webm"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.length) handleImportFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{isDraggingFile && (
|
||||
<div className="absolute inset-0 z-30 pointer-events-none flex items-center justify-center bg-accent/10 border-2 border-dashed border-accent rounded-lg m-4">
|
||||
<div className="flex flex-col items-center gap-2 text-accent">
|
||||
<Music className="h-8 w-8" />
|
||||
<span className="text-sm font-medium">{t('storyContent.dropToImport')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Scroll Mask */}
|
||||
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
|
||||
@@ -329,13 +402,23 @@ export function StoryContent() {
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="end">
|
||||
<div className="p-2 border-b">
|
||||
<div className="p-2 border-b space-y-2">
|
||||
<Input
|
||||
placeholder={t('storyContent.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => importInputRef.current?.click()}
|
||||
disabled={isImporting}
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{isImporting ? t('storyContent.importing') : t('storyContent.importAudio')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{availableGenerations.length === 0 ? (
|
||||
@@ -414,7 +497,11 @@ export function StoryContent() {
|
||||
storyId={story.id}
|
||||
index={index}
|
||||
onRemove={() => handleRemoveItem(item.id)}
|
||||
onRegenerate={() => handleRegenerate(item.generation_id)}
|
||||
onRegenerate={
|
||||
item.engine === 'import'
|
||||
? undefined
|
||||
: () => handleRegenerate(item.generation_id)
|
||||
}
|
||||
currentTimeMs={currentTimeMs}
|
||||
isPlaying={isPlaying && playbackStoryId === story.id}
|
||||
/>
|
||||
|
||||
@@ -1016,16 +1016,18 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleRegenerate}
|
||||
title="Regenerate"
|
||||
aria-label="Regenerate clip"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
{selectedItem?.engine !== 'import' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleRegenerate}
|
||||
title="Regenerate"
|
||||
aria-label="Regenerate clip"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{hasMultipleVersions && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
|
||||
@@ -632,12 +632,16 @@
|
||||
"regenerate": "Regenerate",
|
||||
"removeFromStory": "Remove from Story"
|
||||
},
|
||||
"importAudio": "Import audio…",
|
||||
"importing": "Importing…",
|
||||
"dropToImport": "Drop audio to import",
|
||||
"toast": {
|
||||
"removeFailed": "Failed to remove item",
|
||||
"reorderFailed": "Failed to reorder items",
|
||||
"exportFailed": "Failed to export audio",
|
||||
"addFailed": "Failed to add generation",
|
||||
"regenerateFailed": "Failed to regenerate"
|
||||
"regenerateFailed": "Failed to regenerate",
|
||||
"importFailed": "Failed to import audio"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
|
||||
@@ -632,12 +632,16 @@
|
||||
"regenerate": "再生成",
|
||||
"removeFromStory": "ストーリーから削除"
|
||||
},
|
||||
"importAudio": "オーディオをインポート…",
|
||||
"importing": "インポート中…",
|
||||
"dropToImport": "ドロップしてオーディオをインポート",
|
||||
"toast": {
|
||||
"removeFailed": "項目の削除に失敗しました",
|
||||
"reorderFailed": "項目の並び替えに失敗しました",
|
||||
"exportFailed": "オーディオのエクスポートに失敗しました",
|
||||
"addFailed": "生成の追加に失敗しました",
|
||||
"regenerateFailed": "再生成に失敗しました"
|
||||
"regenerateFailed": "再生成に失敗しました",
|
||||
"importFailed": "オーディオのインポートに失敗しました"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
|
||||
@@ -632,12 +632,16 @@
|
||||
"regenerate": "重新生成",
|
||||
"removeFromStory": "从故事中移除"
|
||||
},
|
||||
"importAudio": "导入音频…",
|
||||
"importing": "正在导入…",
|
||||
"dropToImport": "拖放以导入音频",
|
||||
"toast": {
|
||||
"removeFailed": "移除项目失败",
|
||||
"reorderFailed": "重新排序项目失败",
|
||||
"exportFailed": "导出音频失败",
|
||||
"addFailed": "添加生成失败",
|
||||
"regenerateFailed": "重新生成失败"
|
||||
"regenerateFailed": "重新生成失败",
|
||||
"importFailed": "导入音频失败"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
|
||||
@@ -632,12 +632,16 @@
|
||||
"regenerate": "重新生成",
|
||||
"removeFromStory": "從故事中移除"
|
||||
},
|
||||
"importAudio": "匯入音訊…",
|
||||
"importing": "匯入中…",
|
||||
"dropToImport": "拖放以匯入音訊",
|
||||
"toast": {
|
||||
"removeFailed": "移除項目失敗",
|
||||
"reorderFailed": "重新排序項目失敗",
|
||||
"exportFailed": "匯出音訊失敗",
|
||||
"addFailed": "新增生成失敗",
|
||||
"regenerateFailed": "重新生成失敗"
|
||||
"regenerateFailed": "重新生成失敗",
|
||||
"importFailed": "匯入音訊失敗"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
|
||||
@@ -272,6 +272,20 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async importAudio(file: File): Promise<GenerationResponse> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const res = await fetch(`${this.getBaseUrl()}/generate/import`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => res.statusText);
|
||||
throw new Error(detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async toggleFavorite(generationId: string): Promise<{ is_favorited: boolean }> {
|
||||
return this.request<{ is_favorited: boolean }>(`/history/${generationId}/favorite`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -391,6 +391,7 @@ export interface StoryItemDetail {
|
||||
duration: number;
|
||||
seed?: number;
|
||||
instruct?: string;
|
||||
engine?: string;
|
||||
generation_created_at: string;
|
||||
versions?: GenerationVersionResponse[];
|
||||
active_version_id?: string;
|
||||
|
||||
@@ -597,6 +597,7 @@ class StoryItemDetail(BaseModel):
|
||||
duration: float
|
||||
seed: Optional[int]
|
||||
instruct: Optional[str]
|
||||
engine: Optional[str] = None
|
||||
generation_created_at: datetime
|
||||
# Versions available for this generation
|
||||
versions: Optional[List["GenerationVersionResponse"]] = None
|
||||
|
||||
@@ -3,22 +3,51 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models
|
||||
from .. import config, models
|
||||
from ..services import history, personality, profiles, tts
|
||||
from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db
|
||||
from ..services.generation import run_generation
|
||||
from ..services.task_queue import cancel_generation as cancel_generation_job, enqueue_generation
|
||||
from ..utils.audio import load_audio
|
||||
from ..utils.tasks import get_task_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
IMPORTED_AUDIO_PROFILE_NAME = "Imported Audio"
|
||||
IMPORT_AUDIO_EXTENSIONS = {".wav", ".mp3", ".flac", ".ogg", ".m4a", ".aac", ".webm"}
|
||||
IMPORT_AUDIO_MAX_BYTES = 200 * 1024 * 1024 # 200 MB
|
||||
|
||||
|
||||
def _get_or_create_import_profile(db: Session) -> DBVoiceProfile:
|
||||
"""Singleton profile every imported audio clip points at — keeps the
|
||||
Generation FK happy without making profile_id nullable across the schema."""
|
||||
row = (
|
||||
db.query(DBVoiceProfile)
|
||||
.filter(DBVoiceProfile.name == IMPORTED_AUDIO_PROFILE_NAME)
|
||||
.first()
|
||||
)
|
||||
if row is not None:
|
||||
return row
|
||||
row = DBVoiceProfile(
|
||||
id=str(uuid.uuid4()),
|
||||
name=IMPORTED_AUDIO_PROFILE_NAME,
|
||||
description="External audio imported into a story timeline.",
|
||||
language="en",
|
||||
voice_type="import",
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def _resolve_generation_engine(data: models.GenerationRequest, profile) -> str:
|
||||
return data.engine or getattr(profile, "default_engine", None) or getattr(profile, "preset_engine", None) or "qwen"
|
||||
@@ -371,3 +400,73 @@ async def stream_speech(
|
||||
media_type="audio/wav",
|
||||
headers={"Content-Disposition": 'attachment; filename="speech.wav"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/generate/import", response_model=models.GenerationResponse)
|
||||
async def import_audio(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Register an external audio file as a generation row.
|
||||
|
||||
Designed for the story timeline so users can drop in music or other
|
||||
non-TTS audio. The row points at a singleton "Imported Audio" profile
|
||||
so the existing generation/story plumbing keeps working unchanged."""
|
||||
suffix = Path(file.filename or "").suffix.lower()
|
||||
if suffix not in IMPORT_AUDIO_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unsupported audio format '{suffix}'. Allowed: {sorted(IMPORT_AUDIO_EXTENSIONS)}",
|
||||
)
|
||||
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > IMPORT_AUDIO_MAX_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"File exceeds {IMPORT_AUDIO_MAX_BYTES // (1024 * 1024)} MB limit.",
|
||||
)
|
||||
chunks.append(chunk)
|
||||
audio_bytes = b"".join(chunks)
|
||||
if not audio_bytes:
|
||||
raise HTTPException(status_code=400, detail="Empty audio file.")
|
||||
|
||||
generation_id = str(uuid.uuid4())
|
||||
target = config.get_generations_dir() / f"{generation_id}{suffix}"
|
||||
target.write_bytes(audio_bytes)
|
||||
|
||||
try:
|
||||
audio, sr = load_audio(str(target))
|
||||
duration = float(len(audio) / sr) if sr else 0.0
|
||||
except Exception as decode_err:
|
||||
try:
|
||||
target.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Could not decode audio: {decode_err}",
|
||||
) from decode_err
|
||||
|
||||
profile = _get_or_create_import_profile(db)
|
||||
display_name = Path(file.filename or "Imported audio").stem or "Imported audio"
|
||||
|
||||
return await history.create_generation(
|
||||
profile_id=profile.id,
|
||||
text=display_name,
|
||||
language="en",
|
||||
audio_path=config.to_storage_path(target),
|
||||
duration=duration,
|
||||
seed=None,
|
||||
db=db,
|
||||
generation_id=generation_id,
|
||||
status="completed",
|
||||
engine="import",
|
||||
model_size=None,
|
||||
source="import",
|
||||
)
|
||||
|
||||
@@ -69,6 +69,7 @@ def _build_item_detail(
|
||||
duration=generation.duration,
|
||||
seed=generation.seed,
|
||||
instruct=generation.instruct,
|
||||
engine=generation.engine,
|
||||
generation_created_at=generation.created_at,
|
||||
versions=versions,
|
||||
active_version_id=active_version_id,
|
||||
|
||||
Reference in New Issue
Block a user