FamilyLegacy / app.py
pkheria's picture
reduced prompt size and finally finished
bdd71c6 verified
Raw
History Blame Contribute Delete
26.8 kB
from __future__ import annotations
import spaces
import os
import random
import re
import tempfile
import traceback
import requests
from io import BytesIO
from pathlib import Path
from typing import Any, Optional
import gradio as gr
import librosa
import numpy as np
import soundfile as sf
import torch
torch._dynamo.config.suppress_errors = True
torch._dynamo.reset()
import whisper
from transformers import AutoModelForCausalLM, AutoTokenizer
from voxcpm import VoxCPM
from fastapi.responses import HTMLResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from gradio import Server
from db import (
get_latest_profile_audio,
list_profiles_for_user,
list_relationships_for_user,
relationship_exists_for_user,
save_profile,
save_story_to_qdrant,
search_qdrant,
debug_database,
)
from loguru import logger
# ── Startup DB check ─────────────────────────────────────────────────────────
try:
debug_database()
except Exception:
logger.error(f"Startup DB debug failed: {traceback.format_exc()}")
# ── Lazy model cache (local models only: Whisper + VoxCPM2) ───────────────────
_voxcpm_model = None
_transcriber = None
# ── Lazy model cache ──────────────────────────────────────────────────────────
_story_gen_model = None
_story_gen_tokenizer = None
TRANSCRIBER_MODEL = "base"
RELATION_TRAITS = {
"grandmother": ["warm", "gentle", "loving", "nostalgic", "tender"],
"grandfather": ["kind", "steady", "wise", "calm", "patient"],
"mother": ["tender", "cheerful", "caring", "warm", "loving"],
"father": ["kind", "patient", "gentle", "steady", "calm"],
"sibling": ["cheerful", "playful", "kind", "energetic"],
"friend": ["cheerful", "warm", "kind", "enthusiastic"],
}
DEFAULT_TRAITS = ["kind", "warm", "loving", "calm", "cheerful", "gentle"]
# ── Local model loaders (Whisper + VoxCPM2 only) ─────────────────────────────
def get_story_gen_model():
global _story_gen_model, _story_gen_tokenizer
if _story_gen_model is None:
model_id = "Qwen/Qwen2.5-3B-Instruct"
logger.info(f"Loading Story Generation model: {model_id}...")
try:
_story_gen_tokenizer = AutoTokenizer.from_pretrained(
model_id,
trust_remote_code=True,
)
_story_gen_model = AutoModelForCausalLM.from_pretrained(
model_id,
trust_remote_code=True,
torch_dtype=torch.bfloat16,
)
_story_gen_model.eval()
logger.info("Qwen2.5-3B-Instruct loaded successfully on CPU")
except Exception:
logger.error(f"Failed to load story gen model: {traceback.format_exc()}")
raise
return _story_gen_model, _story_gen_tokenizer
def _generate_story_text_qwen(model, tokenizer, prompt: str) -> str:
messages = [{"role": "user", "content": prompt}]
inputs = tokenizer.apply_chat_template(
messages,
tokenize=True,
add_generation_prompt=True,
return_dict=True,
return_tensors="pt",
).to(model.device)
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=512,
temperature=0.8,
top_p=0.9,
do_sample=True,
repetition_penalty=1.1,
)
response = tokenizer.decode(
outputs[0][inputs["input_ids"].shape[-1]:],
skip_special_tokens=True,
).strip()
logger.info(f"Generated text length: {len(response)}")
return response
@torch._dynamo.disable
def _load_voxcpm():
"""Helper to load VoxCPM with dynamo disabled (must be decorator, not context manager)."""
return VoxCPM.from_pretrained("openbmb/VoxCPM2")
def get_voxcpm_model():
global _voxcpm_model
if _voxcpm_model is None:
logger.info("Loading VoxCPM2 voice synthesis model...")
try:
_voxcpm_model = _load_voxcpm()
logger.info("VoxCPM2 model loaded successfully (CPU).")
except Exception:
logger.error(f"Failed to load VoxCPM2 model: {traceback.format_exc()}")
raise
return _voxcpm_model
def get_transcriber():
global _transcriber
if _transcriber is None:
logger.info(f"Loading Whisper model: {TRANSCRIBER_MODEL}...")
_transcriber = whisper.load_model(TRANSCRIBER_MODEL, device="cpu")
logger.info("Whisper model loaded successfully.")
return _transcriber
def preload_local_models():
"""Download and cache Whisper + VoxCPM2 at startup."""
logger.info("=" * 50)
logger.info("PRELOADING LOCAL MODELS AT STARTUP")
logger.info("=" * 50)
try:
logger.info("Preloading Whisper...")
get_transcriber()
logger.info("Preloading Qwen2.5-3B-Instruct...")
get_story_gen_model()
logger.info("Preloading VoxCPM2...")
get_voxcpm_model()
logger.info("=" * 50)
logger.info("ALL LOCAL MODELS PRELOADED SUCCESSFULLY")
logger.info("=" * 50)
except Exception:
logger.error(f"Model preloading failed: {traceback.format_exc()}")
raise
# ── Audio utilities ───────────────────────────────────────────────────────────
def _extract_path_from_audio_dict(audio: dict) -> str | None:
for key in ("path", "name", "url", "tmp_path"):
val = audio.get(key)
if val and isinstance(val, str) and (os.path.exists(val) or val.startswith("http")):
return val
for val in audio.values():
if isinstance(val, str) and len(val) > 1:
return val
return None
def validate_audio_input(audio: Any, label: str = "audio") -> dict[str, Any]:
logger.info(f"--- Validating {label} ---")
diagnostics: dict[str, Any] = {
"label": label,
"type": str(type(audio)),
"is_none": audio is None,
"valid": False,
"error": None,
}
if audio is None:
diagnostics["error"] = "Audio is None"
logger.warning(f"{label} is None")
return diagnostics
if isinstance(audio, dict):
logger.info(f"{label} received as dict: {list(audio.keys())}")
path = _extract_path_from_audio_dict(audio)
if not path:
diagnostics["error"] = f"Audio dict has no usable path: {audio}"
logger.error(diagnostics["error"])
return diagnostics
diagnostics["type"] = "dict→file"
audio = path
if isinstance(audio, (str, Path)):
path = str(audio)
diagnostics["path"] = path
diagnostics["exists"] = os.path.exists(path)
if diagnostics["exists"]:
diagnostics["size"] = os.path.getsize(path)
logger.info(f"{label} is a file: {path} ({diagnostics['size']} bytes)")
diagnostics["valid"] = diagnostics["size"] > 0
if not diagnostics["valid"]:
diagnostics["error"] = "File is empty"
else:
diagnostics["error"] = "File does not exist"
logger.error(f"{label} file not found: {path}")
return diagnostics
if isinstance(audio, tuple):
diagnostics["is_tuple"] = True
diagnostics["tuple_len"] = len(audio)
if len(audio) == 2:
sr, data = audio
diagnostics["sample_rate"] = sr
diagnostics["data_type"] = str(type(data))
if isinstance(data, np.ndarray):
diagnostics["shape"] = data.shape
diagnostics["size"] = data.size
diagnostics["dtype"] = str(data.dtype)
logger.info(f"{label} is numpy: SR={sr}, shape={data.shape}, dtype={data.dtype}")
diagnostics["valid"] = data.size > 0
if not diagnostics["valid"]:
diagnostics["error"] = "Numpy array is empty"
else:
diagnostics["error"] = "Second element of tuple is not a numpy array"
else:
diagnostics["error"] = f"Expected tuple of length 2, got {len(audio)}"
return diagnostics
diagnostics["error"] = f"Unsupported audio type: {type(audio)}"
logger.warning(f"Unknown audio type for {label}: {type(audio)}")
return diagnostics
def normalize_audio_input(audio: Any) -> tuple[np.ndarray, int]:
if audio is None:
raise ValueError("No audio was provided.")
if isinstance(audio, dict):
logger.info(f"Audio is a dict with keys: {list(audio.keys())}")
path = _extract_path_from_audio_dict(audio)
if not path:
raise ValueError(f"Audio dict has no usable path: {audio}")
logger.info(f"Extracted path from dict: {path}")
audio = path
if isinstance(audio, (str, Path)):
logger.info(f"Loading audio from file using librosa: {audio}")
data, samplerate = librosa.load(str(audio), sr=None, mono=True)
return data, int(samplerate)
if isinstance(audio, tuple):
samplerate, data = audio
if isinstance(data, np.ndarray):
if data.dtype == np.int16:
data = data.astype(np.float32) / 32768.0
elif data.dtype == np.int32:
data = data.astype(np.float32) / 2147483648.0
if len(data.shape) > 1:
data = np.mean(data, axis=1)
return data, int(samplerate)
raise ValueError(f"Unsupported audio type: {type(audio)}")
def audio_to_bytes(audio: Any) -> bytes:
data, sample_rate = normalize_audio_input(audio)
buffer = BytesIO()
sf.write(buffer, data, sample_rate, format="WAV")
return buffer.getvalue()
def write_temp_audio(audio_bytes: bytes) -> str:
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
tmp.write(audio_bytes)
tmp.flush()
tmp.close()
return tmp.name
# ── Core business logic ───────────────────────────────────────────────────────
def transcribe_audio_story(audio: Any) -> str:
if isinstance(audio, dict):
path = _extract_path_from_audio_dict(audio)
if path:
audio = path
diag = validate_audio_input(audio, "story_audio")
if not diag["valid"]:
logger.warning(f"Invalid story audio: {diag['error']}")
return ""
if isinstance(audio, (str, Path)):
audio_path = str(audio)
else:
audio_path = write_temp_audio(audio_to_bytes(audio))
logger.info(f"Transcribing audio story from {audio_path}...")
try:
model = get_transcriber()
result = model.transcribe(audio_path)
text = result.get("text", "").strip()
logger.info(f"Transcription complete: {text[:50]}...")
return text
except Exception as exc:
logger.error(f"Transcription failed: {traceback.format_exc()}")
return f"[transcription error: {exc}]"
def build_profile_table(hf_username: str) -> list[list[str]]:
try:
items = list_profiles_for_user(hf_username)
return [[item["id"], item["relationship"], item["created_at"]] for item in items]
except Exception:
logger.error(f"Error building profile table: {traceback.format_exc()}")
return []
@torch._dynamo.disable
def _run_voxcpm_generate(vox, text, reference_wav_path, cfg_value, inference_timesteps):
"""Helper to run VoxCPM generation with dynamo disabled."""
return vox.generate(
text=text,
reference_wav_path=reference_wav_path,
prompt_wav_path=reference_wav_path,
inference_timesteps=20,
cfg_value=2.0,
prompt_text="The warmth of family is the greatest gift we share. Every laugh, every tear, every quiet moment together becomes a treasure in our hearts. Time may pass, but these memories remain forever, glowing softly like stars in the night sky, guiding us home."
)
def _generate_story_text_qwen(model, tokenizer, prompt: str) -> str:
messages = [{"role": "user", "content": prompt}]
inputs = tokenizer.apply_chat_template(
messages,
tokenize=True,
add_generation_prompt=True,
return_dict=True,
return_tensors="pt",
).to(model.device)
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=256,
temperature=0.8,
top_p=0.9,
do_sample=True,
repetition_penalty=1.1,
)
response = tokenizer.decode(
outputs[0][inputs["input_ids"].shape[-1]:],
skip_special_tokens=True,
).strip()
logger.info(f"Generated text length: {len(response)}")
return response
# ── SINGLE GPU FUNCTION: entire pipeline in one ZeroGPU call ──────────────────
@spaces.GPU
def generate_and_speak(hf_username: str, relationship: str, question: str = "") -> dict:
"""
Single ZeroGPU function that handles the full pipeline.
Text generation is done via external MiniCPM4.1-8B API (CPU/network).
Voice synthesis is done via local VoxCPM2 (GPU).
"""
logger.info(f"Listen Mode: Generating response for {hf_username}/{relationship} | Question: {question}")
if not hf_username or not relationship:
return {"success": False, "status": "Please log in and select a relationship.", "audio_url": None, "text": ""}
audio_bytes = get_latest_profile_audio(hf_username, relationship)
if audio_bytes is None:
logger.warning(f"Reference audio not found for {hf_username}/{relationship}")
return {"success": False, "status": "No reference voice profile found.", "audio_url": None, "text": ""}
ref_audio_path = write_temp_audio(audio_bytes)
try:
# --- Step 0: Context retrieval (CPU) ---
context_memory = search_qdrant(hf_username, relationship, question) if question.strip() else None
if context_memory:
logger.info("Using Qdrant context for generation.")
prompt = (
f"You are a warm, loving {relationship}. "
f"Your family member asked: '{question}'\n\n"
f"Draw from these memories: {context_memory}\n\n"
f"Respond with genuine emotion, specific details, and warmth. "
f"Speak directly to them. Keep it to 1-2 short paragraphs.you are not allowed to exceed 75 words"
)
else:
logger.info("No context found. Using generic heartwarming prompt.")
prompt = (
f"You are a warm, loving {relationship}. "
f"Your family member asked: '{question}'\n\n"
f"Make up a story which is only of 2-3 short paragraphs which answers the question"
f"Make it feel real, emotional, and specific. "
f"Speak directly to them."
)
# --- Step 1: Text Generation (GPU) ---
logger.info("GPU: Loading Qwen2.5-3B into VRAM...")
model, tokenizer = get_story_gen_model()
model = model.cuda()
story_text = _generate_story_text_qwen(model, tokenizer, prompt)
# Offload LLM to free VRAM for TTS
model = model.cpu()
del model
torch.cuda.empty_cache()
logger.info("GPU: Qwen2.5-3B offloaded, VRAM freed.")
# --- Step 1.5: Post-process text (CPU) ---
if not context_memory and question.strip():
story_text = f"My memory fades but : {story_text}"
relation_lower = relationship.lower()
trait_pool = DEFAULT_TRAITS
for key, traits in RELATION_TRAITS.items():
if key in relation_lower:
trait_pool = traits
break
chosen_trait = random.choice(trait_pool)
style_prefix = f"(slow, clear, {chosen_trait} tone) "
styled_story = story_text
# --- Step 2: Voice Synthesis (GPU) ---
logger.info("GPU: Loading VoxCPM2 into VRAM...")
vox = get_voxcpm_model()
# Move internal modules to CUDA
if hasattr(vox, "to"):
vox = vox.to("cuda")
else:
for attr_name in ["model", "tts_model", "vae", "encoder", "decoder"]:
if hasattr(vox, attr_name):
attr = getattr(vox, attr_name)
if hasattr(attr, "cuda"):
setattr(vox, attr_name, attr.cuda())
output_audio_path = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
wav = _run_voxcpm_generate(
vox,
text=styled_story,
reference_wav_path=ref_audio_path,
cfg_value=3.0,
inference_timesteps=10,
)
sf.write(output_audio_path, wav, vox.tts_model.sample_rate)
# Cleanup TTS from VRAM
if hasattr(vox, "cpu"):
vox = vox.cpu()
else:
for attr_name in ["model", "tts_model", "vae", "encoder", "decoder"]:
if hasattr(vox, attr_name):
attr = getattr(vox, attr_name)
if hasattr(attr, "cpu"):
setattr(vox, attr_name, attr.cpu())
del vox
torch.cuda.empty_cache()
logger.info("GPU: VoxCPM2 offloaded, VRAM freed.")
filename = os.path.basename(output_audio_path)
return {
"success": True,
"status": f"Generated for {relationship} ({chosen_trait} tone).",
"audio_url": f"/audio/{filename}",
"text": story_text,
}
except Exception as exc:
logger.error(f"Generation/Synthesis failed: {traceback.format_exc()}")
return {"success": False, "status": f"Generation error: {exc}", "audio_url": None, "text": ""}
# ── API handler functions ─────────────────────────────────────────────────────
def handle_login(hf_username: str) -> dict:
username = (hf_username or "").strip()
logger.info(f"Login requested for username: {username}")
if not username:
logger.warning("Login attempted with empty username.")
return {
"success": False,
"status": "Please enter your Hugging Face username.",
"username": "",
"profiles": [],
"relationships": [],
}
profiles = build_profile_table(username)
relationships = list_relationships_for_user(username)
logger.info(f"Login successful for {username}. Profiles: {len(profiles)}. Relationships: {len(relationships)}.")
return {
"success": True,
"status": f"βœ… Welcome, {username}!",
"username": username,
"profiles": profiles,
"relationships": relationships,
}
def save_voice_profile_only(hf_username: str, relationship: str, audio: Any) -> dict:
logger.info("=== SAVE PROFILE START ===")
logger.info(f"User: {hf_username} | Relationship: {relationship}")
relation = (relationship or "").strip()
if isinstance(audio, dict):
path = _extract_path_from_audio_dict(audio)
if path:
logger.info(f"Unwrapped audio dict β†’ {path}")
audio = path
audio_diag = validate_audio_input(audio, "profile_audio")
if not hf_username:
return {"success": False, "status": "Please log in first.", "profiles": [], "relationships": []}
if not relation:
return {"success": False, "status": "Enter a relationship label.", "profiles": build_profile_table(hf_username), "relationships": list_relationships_for_user(hf_username)}
if not audio_diag["valid"]:
return {"success": False, "status": f"Voice sample issue: {audio_diag['error'] or 'Unknown error'}", "profiles": build_profile_table(hf_username), "relationships": list_relationships_for_user(hf_username)}
try:
audio_bytes = audio_to_bytes(audio)
logger.info(f"Converting profile audio: {len(audio_bytes)} bytes")
save_profile(hf_username, relation, audio_bytes)
logger.info("MongoDB save successful.")
profiles = build_profile_table(hf_username)
relationships = list_relationships_for_user(hf_username)
logger.info("=== SAVE PROFILE COMPLETED SUCCESSFULLY ===")
return {"success": True, "status": "βœ… Voice profile saved successfully.", "profiles": profiles, "relationships": relationships}
except Exception as exc:
logger.error(f"Failed to save voice profile: {traceback.format_exc()}")
return {"success": False, "status": f"Error: {exc}", "profiles": build_profile_table(hf_username), "relationships": list_relationships_for_user(hf_username)}
def save_story_memory(hf_username: str, relationship: str, story_audio: Any) -> dict:
logger.info("=== SAVE STORY START ===")
logger.info(f"User: {hf_username} | Relationship: {relationship}")
relation = (relationship or "").strip()
if isinstance(story_audio, dict):
path = _extract_path_from_audio_dict(story_audio)
if path:
logger.info(f"Unwrapped story_audio dict β†’ {path}")
story_audio = path
story_diag = validate_audio_input(story_audio, "story_audio")
if not hf_username:
return {"success": False, "status": "Please log in first.", "transcript": ""}
if not relation:
return {"success": False, "status": "Please select a relationship for this memory.", "transcript": ""}
if not story_diag["valid"]:
return {"success": False, "status": f"Story audio issue: {story_diag['error'] or 'Unknown error'}", "transcript": ""}
try:
logger.info("Starting Whisper transcription...")
transcript = transcribe_audio_story(story_audio)
logger.info(f"Transcription complete (length: {len(transcript)})")
if not transcript:
return {"success": False, "status": "Transcription was empty. Try speaking more clearly.", "transcript": ""}
logger.info(f"Saving to Qdrant collection: {hf_username}-{relation}")
save_story_to_qdrant(hf_username, relation, transcript)
logger.info("Qdrant storage successful.")
logger.info("=== SAVE STORY COMPLETED SUCCESSFULLY ===")
return {"success": True, "status": "βœ… Memory saved successfully.", "transcript": transcript}
except Exception as exc:
logger.error(f"Failed to save story memory: {traceback.format_exc()}")
return {"success": False, "status": f"Error: {exc}", "transcript": ""}
def refresh_profiles(hf_username: str) -> dict:
relationships = list_relationships_for_user(hf_username)
profiles = build_profile_table(hf_username)
return {"profiles": profiles, "relationships": relationships}
def load_voice_profile(hf_username: str, relationship: str) -> dict:
if not hf_username:
return {"success": False, "status": "Please log in first.", "audio_url": None}
if not relationship:
return {"success": False, "status": "Choose a relationship from the list.", "audio_url": None}
audio_bytes = get_latest_profile_audio(hf_username, relationship)
if audio_bytes is None:
return {"success": False, "status": "No saved voice profile found for that relationship.", "audio_url": None}
audio_path = write_temp_audio(audio_bytes)
filename = os.path.basename(audio_path)
return {"success": True, "status": f"Playing saved voice for {relationship}.", "audio_url": f"/audio/{filename}"}
# ── gradio.Server app ─────────────────────────────────────────────────────────
FRONTEND_DIR = Path(__file__).parent / "static"
app = Server()
@app.api(name="login")
def api_login(hf_username: str) -> dict:
return handle_login(hf_username)
@app.api(name="save_profile")
def api_save_profile(hf_username: str, relationship: str, audio: Optional[str]) -> dict:
return save_voice_profile_only(hf_username, relationship, audio)
@app.api(name="save_story")
def api_save_story(hf_username: str, relationship: str, audio: Optional[str]) -> dict:
return save_story_memory(hf_username, relationship, audio)
@app.api(name="refresh_profiles")
def api_refresh(hf_username: str) -> dict:
return refresh_profiles(hf_username)
@app.api(name="load_voice")
def api_load_voice(hf_username: str, relationship: str) -> dict:
return load_voice_profile(hf_username, relationship)
@app.api(name="generate")
def api_generate(hf_username: str, relationship: str, question: str) -> dict:
return generate_and_speak(hf_username, relationship, question)
@app.api(name="debug_audio")
def api_debug_audio(audio: Optional[str], story_audio: Optional[str]) -> str:
diag1 = validate_audio_input(audio, "profile_audio")
diag2 = validate_audio_input(story_audio, "story_audio")
return f"Profile Audio: {diag1}\n\nStory Audio: {diag2}"
# ── Serve generated audio files ──────────────────────────────────────────────
@app.get("/audio/{filename}")
async def serve_audio(filename: str):
if not filename.endswith(".wav") or ".." in filename or "/" in filename:
return {"error": "Invalid filename"}
file_path = os.path.join("/tmp", filename)
if os.path.exists(file_path):
return FileResponse(file_path, media_type="audio/wav")
return {"error": "File not found"}
# ── Serve custom frontend ─────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def homepage() -> HTMLResponse:
index_path = FRONTEND_DIR / "index.html"
return HTMLResponse(index_path.read_text(encoding="utf-8"))
if FRONTEND_DIR.exists():
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
# ── Entry point ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
# Preload Whisper + VoxCPM2 only. MiniCPM4.1-8B is external API.
preload_local_models()
app.launch(show_error=True, server_port=7860)