import os import uuid import asyncio import json from typing import Dict, Any from fastapi import FastAPI, UploadFile, File, Form from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse, JSONResponse from google import genai from google.genai import types from dotenv import load_dotenv import io import wave import math import struct import random from datetime import datetime from pathlib import Path # Load environment variables from .env if present load_dotenv() # Simple in-memory job store (POC only) jobs: Dict[str, Dict[str, Any]] = {} # Use Hugging Face's writable directory if available, otherwise fall back to local ./data if os.getenv("HF_SPACE"): BASE_DATA_DIR = Path("/data") else: BASE_DATA_DIR = Path(__file__).parent / "data" JOBS_DIR = BASE_DATA_DIR / "jobs" JOBS_DIR.mkdir(parents=True, exist_ok=True) PERSIST_FIELDS = [ "status", "title", "transcript", "speakers", "voices", "use_internet", "saved", "category", "saved_at", "theme", "geo_location", "voice_names", "language", "truncated" ] def _persist_job(job_id: str): j = jobs.get(job_id) if not j: return try: data = {k: j.get(k) for k in PERSIST_FIELDS if k in j} data["job_id"] = job_id with open(JOBS_DIR / f"{job_id}.json", "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False) except Exception as e: print(f"[persist] failed to write job {job_id}: {e}") def _load_job(job_id: str) -> bool: fp = JOBS_DIR / f"{job_id}.json" if not fp.exists(): return False try: with open(fp, "r", encoding="utf-8") as f: data = json.load(f) jobs[job_id] = data # trust stored structure return True except Exception as e: print(f"[persist] failed to load {job_id}: {e}") return False LLM_MODEL_ID = "gemini-2.0-flash" TTS_MODEL_ID = "gemini-2.5-flash-preview-tts" # Approximate generation duration controls # Spoken average: ~2.3–2.7 words/sec. Using 2.5 for estimation. MAX_SPOKEN_SECONDS = 90 # 1 min 30 AVG_WORDS_PER_SECOND = 2.5 MAX_TRANSCRIPT_WORDS = int(MAX_SPOKEN_SECONDS * AVG_WORDS_PER_SECOND) # ~225 def get_client(): api_key = os.getenv("API_KEY") if not api_key: raise RuntimeError("API_KEY not set in environment (.env or system var)") return genai.Client(api_key=api_key) app = FastAPI(title="PodcastAI API", version="0.1.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], # Adjust in production allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.post("/api/generate") async def start_generation( prompt_mode: str = Form(..., regex="^(text|audio)$"), text: str = Form(""), use_internet: bool = Form(False), speakers: str = Form("1"), voices: str = Form(""), # comma separated gender codes (M/F) category: str = Form("generated"), # 'generated' | 'localisation' theme: str = Form(""), # culture | history | music | sport (only for localisation) geo_location: str = Form(""), # free-form location / city / place (only for localisation) audio_file: UploadFile | None = File(None), language: str = Form("") # optional default language code (en, fr, es, de, it, pt, ko, zh) ): # Allow empty text only for localisation (we will synthesize a seed prompt) if prompt_mode == "text" and not text.strip(): if category != "localisation" or not geo_location.strip(): return JSONResponse(status_code=400, content={"error": "Text prompt empty"}) if prompt_mode == "audio" and audio_file is None: return JSONResponse(status_code=400, content={"error": "Audio file missing"}) job_id = str(uuid.uuid4()) jobs[job_id] = { "status": "pending", "transcript": "", "title": None, "use_internet": use_internet, } if language.strip(): jobs[job_id]["language"] = language.strip().lower() # Store initial content (text placeholder may be empty for localisation) if prompt_mode == "text": jobs[job_id]["raw_input"] = text if text.strip() else "" else: # Store placeholder; actual transcription will occur lazily in stream endpoint jobs[job_id]["raw_input"] = "