mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-29 15:15:27 -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 { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
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 { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -85,7 +85,9 @@ export function StoryChatItem({
|
|||||||
{/* Voice Avatar */}
|
{/* Voice Avatar */}
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center overflow-hidden">
|
<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
|
<img
|
||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
alt={`${item.profile_name} avatar`}
|
alt={`${item.profile_name} avatar`}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from '@tanstack/react-router';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
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 { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Loader from 'react-loaders';
|
import Loader from 'react-loaders';
|
||||||
@@ -47,8 +47,12 @@ export function StoryContent() {
|
|||||||
const addStoryItem = useAddStoryItem();
|
const addStoryItem = useAddStoryItem();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const importInputRef = useRef<HTMLInputElement>(null);
|
||||||
const pendingCount = useGenerationStore((s) => s.pendingGenerationIds.size);
|
const pendingCount = useGenerationStore((s) => s.pendingGenerationIds.size);
|
||||||
const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration);
|
const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration);
|
||||||
|
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
||||||
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
|
const dragDepthRef = useRef(0);
|
||||||
|
|
||||||
// Add generation popover state
|
// Add generation popover state
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
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) => {
|
const handleAddGeneration = (generationId: string) => {
|
||||||
if (!story) return;
|
if (!story) return;
|
||||||
|
|
||||||
@@ -284,7 +315,49 @@ export function StoryContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* 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" />
|
<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>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-80 p-0" align="end">
|
<PopoverContent className="w-80 p-0" align="end">
|
||||||
<div className="p-2 border-b">
|
<div className="p-2 border-b space-y-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder={t('storyContent.searchPlaceholder')}
|
placeholder={t('storyContent.searchPlaceholder')}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
autoFocus
|
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>
|
||||||
<div className="max-h-60 overflow-y-auto">
|
<div className="max-h-60 overflow-y-auto">
|
||||||
{availableGenerations.length === 0 ? (
|
{availableGenerations.length === 0 ? (
|
||||||
@@ -414,7 +497,11 @@ export function StoryContent() {
|
|||||||
storyId={story.id}
|
storyId={story.id}
|
||||||
index={index}
|
index={index}
|
||||||
onRemove={() => handleRemoveItem(item.id)}
|
onRemove={() => handleRemoveItem(item.id)}
|
||||||
onRegenerate={() => handleRegenerate(item.generation_id)}
|
onRegenerate={
|
||||||
|
item.engine === 'import'
|
||||||
|
? undefined
|
||||||
|
: () => handleRegenerate(item.generation_id)
|
||||||
|
}
|
||||||
currentTimeMs={currentTimeMs}
|
currentTimeMs={currentTimeMs}
|
||||||
isPlaying={isPlaying && playbackStoryId === story.id}
|
isPlaying={isPlaying && playbackStoryId === story.id}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1016,16 +1016,18 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{selectedItem?.engine !== 'import' && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
className="h-7 w-7"
|
size="icon"
|
||||||
onClick={handleRegenerate}
|
className="h-7 w-7"
|
||||||
title="Regenerate"
|
onClick={handleRegenerate}
|
||||||
aria-label="Regenerate clip"
|
title="Regenerate"
|
||||||
>
|
aria-label="Regenerate clip"
|
||||||
<RotateCcw className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{hasMultipleVersions && (
|
{hasMultipleVersions && (
|
||||||
<>
|
<>
|
||||||
<div className="w-px h-4 bg-border mx-1" />
|
<div className="w-px h-4 bg-border mx-1" />
|
||||||
|
|||||||
@@ -632,12 +632,16 @@
|
|||||||
"regenerate": "Regenerate",
|
"regenerate": "Regenerate",
|
||||||
"removeFromStory": "Remove from Story"
|
"removeFromStory": "Remove from Story"
|
||||||
},
|
},
|
||||||
|
"importAudio": "Import audio…",
|
||||||
|
"importing": "Importing…",
|
||||||
|
"dropToImport": "Drop audio to import",
|
||||||
"toast": {
|
"toast": {
|
||||||
"removeFailed": "Failed to remove item",
|
"removeFailed": "Failed to remove item",
|
||||||
"reorderFailed": "Failed to reorder items",
|
"reorderFailed": "Failed to reorder items",
|
||||||
"exportFailed": "Failed to export audio",
|
"exportFailed": "Failed to export audio",
|
||||||
"addFailed": "Failed to add generation",
|
"addFailed": "Failed to add generation",
|
||||||
"regenerateFailed": "Failed to regenerate"
|
"regenerateFailed": "Failed to regenerate",
|
||||||
|
"importFailed": "Failed to import audio"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"history": {
|
"history": {
|
||||||
|
|||||||
@@ -632,12 +632,16 @@
|
|||||||
"regenerate": "再生成",
|
"regenerate": "再生成",
|
||||||
"removeFromStory": "ストーリーから削除"
|
"removeFromStory": "ストーリーから削除"
|
||||||
},
|
},
|
||||||
|
"importAudio": "オーディオをインポート…",
|
||||||
|
"importing": "インポート中…",
|
||||||
|
"dropToImport": "ドロップしてオーディオをインポート",
|
||||||
"toast": {
|
"toast": {
|
||||||
"removeFailed": "項目の削除に失敗しました",
|
"removeFailed": "項目の削除に失敗しました",
|
||||||
"reorderFailed": "項目の並び替えに失敗しました",
|
"reorderFailed": "項目の並び替えに失敗しました",
|
||||||
"exportFailed": "オーディオのエクスポートに失敗しました",
|
"exportFailed": "オーディオのエクスポートに失敗しました",
|
||||||
"addFailed": "生成の追加に失敗しました",
|
"addFailed": "生成の追加に失敗しました",
|
||||||
"regenerateFailed": "再生成に失敗しました"
|
"regenerateFailed": "再生成に失敗しました",
|
||||||
|
"importFailed": "オーディオのインポートに失敗しました"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"history": {
|
"history": {
|
||||||
|
|||||||
@@ -632,12 +632,16 @@
|
|||||||
"regenerate": "重新生成",
|
"regenerate": "重新生成",
|
||||||
"removeFromStory": "从故事中移除"
|
"removeFromStory": "从故事中移除"
|
||||||
},
|
},
|
||||||
|
"importAudio": "导入音频…",
|
||||||
|
"importing": "正在导入…",
|
||||||
|
"dropToImport": "拖放以导入音频",
|
||||||
"toast": {
|
"toast": {
|
||||||
"removeFailed": "移除项目失败",
|
"removeFailed": "移除项目失败",
|
||||||
"reorderFailed": "重新排序项目失败",
|
"reorderFailed": "重新排序项目失败",
|
||||||
"exportFailed": "导出音频失败",
|
"exportFailed": "导出音频失败",
|
||||||
"addFailed": "添加生成失败",
|
"addFailed": "添加生成失败",
|
||||||
"regenerateFailed": "重新生成失败"
|
"regenerateFailed": "重新生成失败",
|
||||||
|
"importFailed": "导入音频失败"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"history": {
|
"history": {
|
||||||
|
|||||||
@@ -632,12 +632,16 @@
|
|||||||
"regenerate": "重新生成",
|
"regenerate": "重新生成",
|
||||||
"removeFromStory": "從故事中移除"
|
"removeFromStory": "從故事中移除"
|
||||||
},
|
},
|
||||||
|
"importAudio": "匯入音訊…",
|
||||||
|
"importing": "匯入中…",
|
||||||
|
"dropToImport": "拖放以匯入音訊",
|
||||||
"toast": {
|
"toast": {
|
||||||
"removeFailed": "移除項目失敗",
|
"removeFailed": "移除項目失敗",
|
||||||
"reorderFailed": "重新排序項目失敗",
|
"reorderFailed": "重新排序項目失敗",
|
||||||
"exportFailed": "匯出音訊失敗",
|
"exportFailed": "匯出音訊失敗",
|
||||||
"addFailed": "新增生成失敗",
|
"addFailed": "新增生成失敗",
|
||||||
"regenerateFailed": "重新生成失敗"
|
"regenerateFailed": "重新生成失敗",
|
||||||
|
"importFailed": "匯入音訊失敗"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"history": {
|
"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 }> {
|
async toggleFavorite(generationId: string): Promise<{ is_favorited: boolean }> {
|
||||||
return this.request<{ is_favorited: boolean }>(`/history/${generationId}/favorite`, {
|
return this.request<{ is_favorited: boolean }>(`/history/${generationId}/favorite`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -391,6 +391,7 @@ export interface StoryItemDetail {
|
|||||||
duration: number;
|
duration: number;
|
||||||
seed?: number;
|
seed?: number;
|
||||||
instruct?: string;
|
instruct?: string;
|
||||||
|
engine?: string;
|
||||||
generation_created_at: string;
|
generation_created_at: string;
|
||||||
versions?: GenerationVersionResponse[];
|
versions?: GenerationVersionResponse[];
|
||||||
active_version_id?: string;
|
active_version_id?: string;
|
||||||
|
|||||||
@@ -597,6 +597,7 @@ class StoryItemDetail(BaseModel):
|
|||||||
duration: float
|
duration: float
|
||||||
seed: Optional[int]
|
seed: Optional[int]
|
||||||
instruct: Optional[str]
|
instruct: Optional[str]
|
||||||
|
engine: Optional[str] = None
|
||||||
generation_created_at: datetime
|
generation_created_at: datetime
|
||||||
# Versions available for this generation
|
# Versions available for this generation
|
||||||
versions: Optional[List["GenerationVersionResponse"]] = None
|
versions: Optional[List["GenerationVersionResponse"]] = None
|
||||||
|
|||||||
@@ -3,22 +3,51 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
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 fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .. import models
|
from .. import config, models
|
||||||
from ..services import history, personality, profiles, tts
|
from ..services import history, personality, profiles, tts
|
||||||
from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db
|
from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db
|
||||||
from ..services.generation import run_generation
|
from ..services.generation import run_generation
|
||||||
from ..services.task_queue import cancel_generation as cancel_generation_job, enqueue_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
|
from ..utils.tasks import get_task_manager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
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:
|
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"
|
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",
|
media_type="audio/wav",
|
||||||
headers={"Content-Disposition": 'attachment; filename="speech.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,
|
duration=generation.duration,
|
||||||
seed=generation.seed,
|
seed=generation.seed,
|
||||||
instruct=generation.instruct,
|
instruct=generation.instruct,
|
||||||
|
engine=generation.engine,
|
||||||
generation_created_at=generation.created_at,
|
generation_created_at=generation.created_at,
|
||||||
versions=versions,
|
versions=versions,
|
||||||
active_version_id=active_version_id,
|
active_version_id=active_version_id,
|
||||||
|
|||||||
Reference in New Issue
Block a user