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:
Jamie Pine
2026-04-25 01:55:49 -07:00
parent 2a937983c5
commit c43f2d45cc
12 changed files with 245 additions and 22 deletions
@@ -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`}
+91 -4
View File
@@ -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" />
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -632,12 +632,16 @@
"regenerate": "再生成",
"removeFromStory": "ストーリーから削除"
},
"importAudio": "オーディオをインポート…",
"importing": "インポート中…",
"dropToImport": "ドロップしてオーディオをインポート",
"toast": {
"removeFailed": "項目の削除に失敗しました",
"reorderFailed": "項目の並び替えに失敗しました",
"exportFailed": "オーディオのエクスポートに失敗しました",
"addFailed": "生成の追加に失敗しました",
"regenerateFailed": "再生成に失敗しました"
"regenerateFailed": "再生成に失敗しました",
"importFailed": "オーディオのインポートに失敗しました"
}
},
"history": {
+5 -1
View File
@@ -632,12 +632,16 @@
"regenerate": "重新生成",
"removeFromStory": "从故事中移除"
},
"importAudio": "导入音频…",
"importing": "正在导入…",
"dropToImport": "拖放以导入音频",
"toast": {
"removeFailed": "移除项目失败",
"reorderFailed": "重新排序项目失败",
"exportFailed": "导出音频失败",
"addFailed": "添加生成失败",
"regenerateFailed": "重新生成失败"
"regenerateFailed": "重新生成失败",
"importFailed": "导入音频失败"
}
},
"history": {
+5 -1
View File
@@ -632,12 +632,16 @@
"regenerate": "重新生成",
"removeFromStory": "從故事中移除"
},
"importAudio": "匯入音訊…",
"importing": "匯入中…",
"dropToImport": "拖放以匯入音訊",
"toast": {
"removeFailed": "移除項目失敗",
"reorderFailed": "重新排序項目失敗",
"exportFailed": "匯出音訊失敗",
"addFailed": "新增生成失敗",
"regenerateFailed": "重新生成失敗"
"regenerateFailed": "重新生成失敗",
"importFailed": "匯入音訊失敗"
}
},
"history": {
+14
View File
@@ -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',
+1
View File
@@ -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;
+1
View File
@@ -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
+101 -2
View File
@@ -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",
)
+1
View File
@@ -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,