jameszokah commited on
Commit
74c62a2
·
1 Parent(s): 23beeea

Initialize database and add storage directories; include audiobook routes

Browse files
Files changed (4) hide show
  1. app/api/audiobook_routes.py +212 -0
  2. app/db.py +39 -0
  3. app/main.py +14 -0
  4. app/services/storage.py +81 -0
app/api/audiobook_routes.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Audiobook creation routes for the CSM-1B TTS API.
3
+ """
4
+ import os
5
+ import uuid
6
+ import logging
7
+ from datetime import datetime
8
+ from typing import Optional, List
9
+ from fastapi import APIRouter, Request, HTTPException, BackgroundTasks, UploadFile, File, Form, Depends
10
+ from fastapi.responses import FileResponse, JSONResponse
11
+ from sqlalchemy.orm import Session
12
+ from app.models.database import Audiobook, AudiobookStatus, AudiobookChunk
13
+ from app.services.storage import storage
14
+ from app.db import get_db
15
+ import torchaudio
16
+
17
+ # Set up logging
18
+ logger = logging.getLogger(__name__)
19
+ router = APIRouter(prefix="/audiobook", tags=["Audiobook"])
20
+
21
+ async def process_audiobook(
22
+ request: Request,
23
+ book_id: str,
24
+ text_content: str,
25
+ voice_id: int,
26
+ db: Session
27
+ ):
28
+ """Process audiobook in the background."""
29
+ try:
30
+ # Get the book from database
31
+ book = db.query(Audiobook).filter(Audiobook.id == book_id).first()
32
+ if not book:
33
+ logger.error(f"Book {book_id} not found")
34
+ return False
35
+
36
+ # Update status to processing
37
+ book.status = AudiobookStatus.PROCESSING
38
+ db.commit()
39
+
40
+ logger.info(f"Starting processing for audiobook {book_id}")
41
+
42
+ # Get the generator from app state
43
+ generator = request.app.state.generator
44
+ if generator is None:
45
+ raise Exception("TTS model not available")
46
+
47
+ # Get voice info
48
+ voice_info = request.app.state.get_voice_info(voice_id)
49
+ if not voice_info:
50
+ raise Exception(f"Voice ID {voice_id} not found")
51
+
52
+ # Generate audio for the entire text
53
+ logger.info(f"Generating audio for entire text of book {book_id}")
54
+ audio = generator.generate(
55
+ text=text_content,
56
+ speaker=voice_info["speaker_id"],
57
+ max_audio_length_ms=min(300000, len(text_content) * 80) # Big text = big audio
58
+ )
59
+
60
+ if audio is None:
61
+ raise Exception("Failed to generate audio")
62
+
63
+ # Save the audio using storage service
64
+ audio_to_save = audio.unsqueeze(0).cpu() if len(audio.shape) == 1 else audio.cpu()
65
+ audio_bytes = audio_to_save.numpy().tobytes()
66
+ audio_path = await storage.save_audio_file(book_id, audio_bytes)
67
+
68
+ # Update book status in database
69
+ book.status = AudiobookStatus.COMPLETED
70
+ book.audio_file_path = audio_path
71
+ db.commit()
72
+
73
+ logger.info(f"Successfully created audiobook {book_id}")
74
+ return True
75
+
76
+ except Exception as e:
77
+ logger.error(f"Error processing audiobook {book_id}: {e}")
78
+
79
+ # Update status to failed in database
80
+ book = db.query(Audiobook).filter(Audiobook.id == book_id).first()
81
+ if book:
82
+ book.status = AudiobookStatus.FAILED
83
+ book.error_message = str(e)
84
+ db.commit()
85
+
86
+ return False
87
+
88
+ @router.post("/")
89
+ async def create_audiobook(
90
+ request: Request,
91
+ background_tasks: BackgroundTasks,
92
+ title: str = Form(...),
93
+ author: str = Form(...),
94
+ voice_id: int = Form(0),
95
+ text_file: Optional[UploadFile] = File(None),
96
+ text_content: Optional[str] = Form(None),
97
+ db: Session = Depends(get_db)
98
+ ):
99
+ """Create a new audiobook from text."""
100
+ try:
101
+ # Validate input
102
+ if not text_file and not text_content:
103
+ raise HTTPException(status_code=400, detail="Either text_file or text_content is required")
104
+
105
+ # Generate unique ID
106
+ book_id = str(uuid.uuid4())
107
+
108
+ # Handle text content
109
+ if text_file:
110
+ text_file_path = await storage.save_text_file(book_id, text_file)
111
+ with open(text_file_path, "r", encoding="utf-8") as f:
112
+ text_content = f.read()
113
+ else:
114
+ text_file_path = await storage.save_text_content(book_id, text_content)
115
+
116
+ # Create book in database
117
+ book = Audiobook(
118
+ id=book_id,
119
+ title=title,
120
+ author=author,
121
+ voice_id=voice_id,
122
+ status=AudiobookStatus.PENDING,
123
+ text_file_path=text_file_path,
124
+ text_content=text_content if len(text_content) <= 10000 else None # Store small texts directly
125
+ )
126
+ db.add(book)
127
+ db.commit()
128
+
129
+ # Process in background
130
+ background_tasks.add_task(process_audiobook, request, book_id, text_content, voice_id, db)
131
+
132
+ return JSONResponse(content={"message": "Audiobook creation started", "book_id": book_id})
133
+ except Exception as e:
134
+ raise HTTPException(status_code=500, detail=f"Error creating audiobook: {str(e)}")
135
+
136
+ @router.get("/{book_id}")
137
+ async def get_audiobook(book_id: str, db: Session = Depends(get_db)):
138
+ """Get audiobook information."""
139
+ book = db.query(Audiobook).filter(Audiobook.id == book_id).first()
140
+ if not book:
141
+ raise HTTPException(status_code=404, detail="Audiobook not found")
142
+
143
+ return {
144
+ "id": book.id,
145
+ "title": book.title,
146
+ "author": book.author,
147
+ "voice_id": book.voice_id,
148
+ "status": book.status.value,
149
+ "created_at": book.created_at.isoformat(),
150
+ "updated_at": book.updated_at.isoformat(),
151
+ "error_message": book.error_message
152
+ }
153
+
154
+ @router.get("/{book_id}/audio")
155
+ async def get_audiobook_audio(book_id: str, db: Session = Depends(get_db)):
156
+ """Get the audiobook audio file."""
157
+ book = db.query(Audiobook).filter(Audiobook.id == book_id).first()
158
+ if not book:
159
+ raise HTTPException(status_code=404, detail="Audiobook not found")
160
+
161
+ if book.status != AudiobookStatus.COMPLETED or not book.audio_file_path:
162
+ raise HTTPException(status_code=400, detail="Audiobook is not yet completed")
163
+
164
+ audio_path = await storage.get_audio_file(book_id)
165
+ if not audio_path:
166
+ raise HTTPException(status_code=404, detail="Audio file not found")
167
+
168
+ return FileResponse(
169
+ str(audio_path),
170
+ media_type="audio/wav",
171
+ filename=f"{book.title}.wav"
172
+ )
173
+
174
+ @router.get("/")
175
+ async def get_audiobooks(db: Session = Depends(get_db)):
176
+ """Get all audiobooks."""
177
+ books = db.query(Audiobook).order_by(Audiobook.created_at.desc()).all()
178
+ return {
179
+ "audiobooks": [
180
+ {
181
+ "id": book.id,
182
+ "title": book.title,
183
+ "author": book.author,
184
+ "voice_id": book.voice_id,
185
+ "status": book.status.value,
186
+ "created_at": book.created_at.isoformat(),
187
+ "updated_at": book.updated_at.isoformat(),
188
+ "error_message": book.error_message
189
+ }
190
+ for book in books
191
+ ]
192
+ }
193
+
194
+ @router.delete("/{book_id}")
195
+ async def delete_audiobook(book_id: str, db: Session = Depends(get_db)):
196
+ """Delete an audiobook."""
197
+ book = db.query(Audiobook).filter(Audiobook.id == book_id).first()
198
+ if not book:
199
+ raise HTTPException(status_code=404, detail="Audiobook not found")
200
+
201
+ try:
202
+ # Delete associated files
203
+ await storage.delete_book_files(book_id)
204
+
205
+ # Delete from database
206
+ db.delete(book)
207
+ db.commit()
208
+
209
+ return {"message": "Audiobook deleted successfully"}
210
+ except Exception as e:
211
+ db.rollback()
212
+ raise HTTPException(status_code=500, detail=f"Error deleting audiobook: {str(e)}")
app/db.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Database connection and session management."""
2
+ import os
3
+ from sqlalchemy import create_engine
4
+ from sqlalchemy.orm import sessionmaker, Session
5
+ from contextlib import contextmanager
6
+ from app.models.database import Base
7
+
8
+ # Get database URL from environment or use SQLite as default
9
+ DATABASE_URL = os.environ.get(
10
+ "DATABASE_URL",
11
+ "sqlite:///app/audiobooks.db"
12
+ )
13
+
14
+ # Create engine
15
+ engine = create_engine(
16
+ DATABASE_URL,
17
+ echo=False, # Set to True for SQL logging
18
+ pool_pre_ping=True, # Enable connection health checks
19
+ )
20
+
21
+ # Create session factory
22
+ SessionLocal = sessionmaker(
23
+ bind=engine,
24
+ autocommit=False,
25
+ autoflush=False,
26
+ )
27
+
28
+ @contextmanager
29
+ def get_db() -> Session:
30
+ """Get database session."""
31
+ db = SessionLocal()
32
+ try:
33
+ yield db
34
+ finally:
35
+ db.close()
36
+
37
+ def init_db():
38
+ """Initialize database."""
39
+ Base.metadata.create_all(bind=engine)
app/main.py CHANGED
@@ -53,6 +53,12 @@ async def lifespan(app: FastAPI):
53
  app.state.generator = None # Will be populated later if model loads
54
  app.state.logger = logger # Make logger available to routes
55
 
 
 
 
 
 
 
56
  # Create necessary directories - use persistent locations
57
  APP_DIR = "/app"
58
  os.makedirs(os.path.join(APP_DIR, "models"), exist_ok=True)
@@ -63,6 +69,9 @@ async def lifespan(app: FastAPI):
63
  os.makedirs(os.path.join(APP_DIR, "cloned_voices"), exist_ok=True)
64
  os.makedirs(os.path.join(APP_DIR, "audio_cache"), exist_ok=True)
65
  os.makedirs(os.path.join(APP_DIR, "static"), exist_ok=True)
 
 
 
66
 
67
  # Set tokenizer cache
68
  try:
@@ -520,6 +529,11 @@ from app.api.streaming import router as streaming_router
520
  app.include_router(streaming_router, prefix="/api/v1")
521
  app.include_router(streaming_router, prefix="/v1")
522
 
 
 
 
 
 
523
  # Middleware for request timing
524
  @app.middleware("http")
525
  async def add_process_time_header(request: Request, call_next):
 
53
  app.state.generator = None # Will be populated later if model loads
54
  app.state.logger = logger # Make logger available to routes
55
 
56
+ # Initialize database
57
+ from app.db import init_db
58
+ logger.info("Initializing database...")
59
+ init_db()
60
+ logger.info("Database initialized")
61
+
62
  # Create necessary directories - use persistent locations
63
  APP_DIR = "/app"
64
  os.makedirs(os.path.join(APP_DIR, "models"), exist_ok=True)
 
69
  os.makedirs(os.path.join(APP_DIR, "cloned_voices"), exist_ok=True)
70
  os.makedirs(os.path.join(APP_DIR, "audio_cache"), exist_ok=True)
71
  os.makedirs(os.path.join(APP_DIR, "static"), exist_ok=True)
72
+ os.makedirs(os.path.join(APP_DIR, "storage/audio"), exist_ok=True) # For audio files
73
+ os.makedirs(os.path.join(APP_DIR, "storage/text"), exist_ok=True) # For text files
74
+ os.makedirs(os.path.join(APP_DIR, "audiobooks"), exist_ok=True) # Add audiobooks directory
75
 
76
  # Set tokenizer cache
77
  try:
 
529
  app.include_router(streaming_router, prefix="/api/v1")
530
  app.include_router(streaming_router, prefix="/v1")
531
 
532
+ # Add audiobook routes
533
+ from app.api.audiobook_routes import router as audiobook_router
534
+ app.include_router(audiobook_router, prefix="/api/v1")
535
+ app.include_router(audiobook_router, prefix="/v1")
536
+
537
  # Middleware for request timing
538
  @app.middleware("http")
539
  async def add_process_time_header(request: Request, call_next):
app/services/storage.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Storage service for managing audio files."""
2
+ import os
3
+ import shutil
4
+ from pathlib import Path
5
+ from typing import Optional, BinaryIO
6
+ from fastapi import UploadFile
7
+ import aiofiles
8
+
9
+ class StorageService:
10
+ """Service for managing file storage."""
11
+
12
+ def __init__(self, base_path: str = "/app/storage"):
13
+ """Initialize storage service."""
14
+ self.base_path = Path(base_path)
15
+ self.audio_path = self.base_path / "audio"
16
+ self.text_path = self.base_path / "text"
17
+
18
+ # Create directories
19
+ self.audio_path.mkdir(parents=True, exist_ok=True)
20
+ self.text_path.mkdir(parents=True, exist_ok=True)
21
+
22
+ async def save_audio_file(self, book_id: str, audio_data: bytes) -> str:
23
+ """Save audio file to storage."""
24
+ file_path = self.audio_path / f"{book_id}.wav"
25
+ async with aiofiles.open(file_path, "wb") as f:
26
+ await f.write(audio_data)
27
+ return str(file_path)
28
+
29
+ async def save_text_file(self, book_id: str, text_file: UploadFile) -> str:
30
+ """Save text file to storage."""
31
+ file_path = self.text_path / f"{book_id}.txt"
32
+ async with aiofiles.open(file_path, "wb") as f:
33
+ content = await text_file.read()
34
+ await f.write(content)
35
+ return str(file_path)
36
+
37
+ async def save_text_content(self, book_id: str, text_content: str) -> str:
38
+ """Save text content to a file."""
39
+ file_path = self.text_path / f"{book_id}.txt"
40
+ async with aiofiles.open(file_path, "w", encoding="utf-8") as f:
41
+ await f.write(text_content)
42
+ return str(file_path)
43
+
44
+ async def get_audio_file(self, book_id: str) -> Optional[Path]:
45
+ """Get audio file path."""
46
+ file_path = self.audio_path / f"{book_id}.wav"
47
+ return file_path if file_path.exists() else None
48
+
49
+ async def get_text_file(self, book_id: str) -> Optional[Path]:
50
+ """Get text file path."""
51
+ file_path = self.text_path / f"{book_id}.txt"
52
+ return file_path if file_path.exists() else None
53
+
54
+ async def delete_book_files(self, book_id: str):
55
+ """Delete all files associated with a book."""
56
+ # Delete audio file
57
+ audio_file = self.audio_path / f"{book_id}.wav"
58
+ if audio_file.exists():
59
+ audio_file.unlink()
60
+
61
+ # Delete text file
62
+ text_file = self.text_path / f"{book_id}.txt"
63
+ if text_file.exists():
64
+ text_file.unlink()
65
+
66
+ def cleanup_orphaned_files(self, valid_book_ids: set[str]):
67
+ """Clean up files that don't belong to any book."""
68
+ # Clean up audio files
69
+ for file_path in self.audio_path.glob("*.wav"):
70
+ book_id = file_path.stem
71
+ if book_id not in valid_book_ids:
72
+ file_path.unlink()
73
+
74
+ # Clean up text files
75
+ for file_path in self.text_path.glob("*.txt"):
76
+ book_id = file_path.stem
77
+ if book_id not in valid_book_ids:
78
+ file_path.unlink()
79
+
80
+ # Create global instance
81
+ storage = StorageService()