import io import os import re import smtplib import subprocess import uuid import warnings from contextlib import asynccontextmanager from datetime import datetime from email.mime.application import MIMEApplication from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from pathlib import Path from dotenv import load_dotenv load_dotenv() os.environ["TORCH_CODEC_ENABLED"] = "0" warnings.filterwarnings("ignore", category=UserWarning, module="pyannote.audio") warnings.filterwarnings("ignore", category=UserWarning, module="torchaudio") from fastapi import FastAPI, UploadFile, File, Form, Body, HTTPException from fastapi.responses import HTMLResponse import whisperx import torch MAX_FILE_SIZE = 25 * 1024 * 1024 ALLOWED_CONTENT_TYPES = { "audio/mpeg", "audio/mp3", "audio/webm", "audio/ogg", "audio/wav", "video/webm", } DATA_DIR = Path("data") DATA_DIR.mkdir(exist_ok=True) OUTPUT_DIR = Path("output") OUTPUT_DIR.mkdir(exist_ok=True) device = "cuda" if torch.cuda.is_available() else "cpu" compute_type = "float32" if device == "cpu" else "float16" _whisper_model = None _align_model = None _align_metadata = None # ── Model loading ────────────────────────────────────────────────────────────── def get_whisper_model(): global _whisper_model if _whisper_model is None: print(f"Lade WhisperX Modell large-v3 auf {device} ({compute_type})...") _whisper_model = whisperx.load_model( "large-v3", device=device, compute_type=compute_type ) print("WhisperX Modell geladen.") return _whisper_model def get_align_model(): global _align_model, _align_metadata if _align_model is None: print("Lade Alignment Modell (de)...") _align_model, _align_metadata = whisperx.load_align_model( language_code="de", device=device ) print("Alignment Modell geladen.") return _align_model, _align_metadata @asynccontextmanager async def lifespan(app: FastAPI): print(f"Server startet – lade Modelle auf {device}...") get_whisper_model() get_align_model() print("Alle Modelle geladen – Server bereit!") yield print("Server fährt herunter.") app = FastAPI(lifespan=lifespan) # ── Audio helpers ────────────────────────────────────────────────────────────── def convert_to_mp3(input_path: Path) -> Path: output_path = input_path.with_suffix(".converted.mp3") r = subprocess.run( [ "ffmpeg", "-y", "-i", str(input_path), "-acodec", "libmp3lame", "-q:a", "2", str(output_path), ], capture_output=True, text=True, ) if r.returncode != 0: raise RuntimeError(f"ffmpeg fehlgeschlagen:\n{r.stderr}") return output_path def detect_audio_format(data: bytes) -> str: if data[:3] == b"ID3" or data[:2] in (b"\xff\xfb", b"\xff\xf3"): return "mp3" if data[:4] == b"fLaC": return "flac" if data[:4] == b"OggS": return "ogg" if data[:4] == b"RIFF": return "wav" if data[:4] == b"\x1aE\xdf\xa3": return "webm" return "unknown" # ── Transcription ────────────────────────────────────────────────────────────── def transcribe_audio(file_path: Path) -> str: whisper_model = get_whisper_model() align_model, align_metadata = get_align_model() fmt = detect_audio_format(file_path.read_bytes()) needs_conversion = ( fmt in ("webm", "ogg", "flac") or file_path.suffix.lower() != ".mp3" ) audio_path = convert_to_mp3(file_path) if needs_conversion else file_path audio_str = str(audio_path) print("Transkription...") result = whisper_model.transcribe(audio_str, language="de") print("Alignment...") result = whisperx.align( result["segments"], align_model, align_metadata, audio_str, device=device ) if os.getenv("ENABLE_SPEAKER_DIARIZATION", "true").lower() == "true": try: print("Diarization...") from whisperx.diarize import DiarizationPipeline pipeline = DiarizationPipeline(token=os.getenv("HF_TOKEN"), device=device) diarize_segments = pipeline(audio_str) result = whisperx.assign_word_speakers(diarize_segments, result) print("Diarization fertig.") except Exception as e: print(f"Diarization fehlgeschlagen: {e}") if needs_conversion: audio_path.unlink(missing_ok=True) return "\n".join( f"{seg.get('speaker', 'SPRECHER')}: {seg['text'].strip()}" for seg in result["segments"] if seg["text"].strip() ) # ── AI helpers ───────────────────────────────────────────────────────────────── def _openai_client(): from openai import OpenAI key = os.getenv("OPENAI_API_KEY") if not key: raise RuntimeError("OPENAI_API_KEY nicht gesetzt") return OpenAI(api_key=key) def process_with_ai(text: str) -> str: """Grammatik- und Interpunktionskorrektur, Sprecher-Labels bleiben.""" client = _openai_client() r = client.chat.completions.create( model="gpt-4o", messages=[ { "role": "system", "content": ( "Du bist ein Assistent zur Nachbearbeitung von Sprachtranskripten. " "Verbessere Grammatik, Rechtschreibung und Interpunktion. " "Behalte die Sprecher-Labels (z.B. 'Max Mustermann:') exakt bei. " "Verändere die inhaltliche Bedeutung NICHT. " "Format: Sprecher: Text" ), }, {"role": "user", "content": text}, ], ) return r.choices[0].message.content or "" def summarize_with_ai(transcript: str, title: str) -> str: """Erstellt eine strukturierte Meeting-Zusammenfassung ohne Markdown.""" client = _openai_client() r = client.chat.completions.create( model="gpt-4o", messages=[ { "role": "system", "content": ( "Du bist ein Assistent für Meeting-Protokolle. " "Erstelle eine strukturierte Zusammenfassung des Meeting-Transkripts. " "Gliedere immer in genau diese drei Abschnitte, auch wenn das Transkript kurz oder unvollständig ist – " "fasse dann zusammen was vorhanden ist:\n\n" "Kernthemen:\n[Was wurde besprochen]\n\n" "Entscheidungen:\n[Getroffene Entscheidungen, falls keine: 'Keine Entscheidungen getroffen.']\n\n" "Nächste Schritte:\n[Offene Punkte und Aufgaben, falls keine: 'Keine Folgeaufgaben definiert.']\n\n" "Wichtige Regeln:\n" "- Kein Markdown, keine Sterne, keine Rauten, keine Aufzählungszeichen mit Bindestrich\n" "- Nur Fließtext und Zeilenumbrüche\n" "- Professionelles Deutsch\n" "- Immer alle drei Abschnitte ausgeben, egal wie kurz das Transkript ist" ), }, { "role": "user", "content": f"Meeting: {title}\n\nTranskript:\n{transcript}", }, ], ) return r.choices[0].message.content or "" # ── PDF generation ───────────────────────────────────────────────────────────── def _safe_filename(title: str) -> str: """Erstellt einen sicheren Dateinamen aus dem Meeting-Titel.""" safe = re.sub(r"[^\w\-äöüÄÖÜß ]", "", title).strip() safe = re.sub(r"\s+", "_", safe) return safe or "Transkript" def build_pdf(title: str, summary: str, transcript: str) -> bytes: """Professionelles PDF: heller Hintergrund, Noto Sans, saubere Typografie.""" from fpdf import FPDF # Font paths FONT_DIR = Path("/usr/share/fonts/truetype/noto") F_REG = str(FONT_DIR / "NotoSans-Regular.ttf") F_BOLD = str(FONT_DIR / "NotoSans-Bold.ttf") F_ITAL = str(FONT_DIR / "NotoSans-Italic.ttf") # Colour palette (fully print-friendly, dark on white) C_HEADER_BG = (248, 248, 250) # very light grey header band C_HEADER_FG = (20, 20, 30) # near-black title C_HEADER_SUB = (120, 120, 135) # label / date in header C_HEADER_LINE = (200, 200, 210) # bottom border of header band C_LABEL = (130, 130, 145) # section labels C_RULE = (220, 220, 228) # horizontal rules C_BODY = (30, 30, 40) # main body text C_SUMMARY = (55, 55, 70) # summary text C_FOOTER = (160, 160, 175) # Speaker accent colours (left border + name) — muted, print-safe SPEAKER_PALETTE = [ (51, 102, 187), # blue (180, 60, 60), # red (40, 140, 80), # green (140, 70, 170), # purple (190, 120, 20), # amber (20, 150, 160), # teal (190, 80, 120), # rose (80, 100, 180), # indigo ] date_str = datetime.now().strftime("%d. %B %Y") class PDF(FPDF): def __init__(self): super().__init__() self.add_font("Noto", "", F_REG, uni=True) self.add_font("Noto", "B", F_BOLD, uni=True) self.add_font("Noto", "I", F_ITAL, uni=True) def header(self): if self.page_no() == 1: return # cover drawn manually # Running header on pages 2+ self.set_font("Noto", "", 8) self.set_text_color(*C_LABEL) self.cell(0, 6, title, align="L") self.cell(0, 6, date_str, align="R", new_x="LMARGIN", new_y="NEXT") self.set_draw_color(*C_RULE) self.set_line_width(0.3) self.line(self.l_margin, self.get_y(), self.w - self.r_margin, self.get_y()) self.ln(5) def footer(self): self.set_y(-14) self.set_font("Noto", "", 8) self.set_text_color(*C_FOOTER) self.cell(0, 8, f"{self.page_no()}", align="C") pdf = PDF() pdf.set_margins(22, 28, 22) pdf.set_auto_page_break(auto=True, margin=20) # ── PAGE 1: Cover block ─────────────────────────────────────────────────── pdf.add_page() # Light header band pdf.set_fill_color(*C_HEADER_BG) pdf.rect(0, 0, pdf.w, 52, style="F") # Bottom border line pdf.set_draw_color(*C_HEADER_LINE) pdf.set_line_width(0.4) pdf.line(0, 52, pdf.w, 52) # "MEETING-PROTOKOLL" label pdf.set_xy(22, 13) pdf.set_font("Noto", "", 8) pdf.set_text_color(*C_HEADER_SUB) pdf.cell(0, 5, "MEETING-PROTOKOLL") # Title pdf.set_xy(22, 21) pdf.set_font("Noto", "B", 20) pdf.set_text_color(*C_HEADER_FG) pdf.cell(0, 10, title) # Date pdf.set_xy(22, 38) pdf.set_font("Noto", "", 9) pdf.set_text_color(*C_HEADER_SUB) pdf.cell(0, 5, date_str) pdf.set_y(62) # below header band # ── SUMMARY SECTION ─────────────────────────────────────────────────────── if summary: # Section label pdf.set_font("Noto", "B", 8) pdf.set_text_color(*C_LABEL) pdf.set_x(pdf.l_margin) pdf.cell(0, 5, "ZUSAMMENFASSUNG", new_x="LMARGIN", new_y="NEXT") # Rule pdf.set_draw_color(*C_RULE) pdf.set_line_width(0.3) pdf.line(pdf.l_margin, pdf.get_y() + 1, pdf.w - pdf.r_margin, pdf.get_y() + 1) pdf.ln(5) # Summary text — render each paragraph body_w = pdf.w - pdf.l_margin - pdf.r_margin pdf.set_font("Noto", "", 10) pdf.set_text_color(*C_SUMMARY) for para in summary.strip().split("\n"): para = para.strip() if not para: pdf.ln(3) continue pdf.set_x(pdf.l_margin) # Section headers inside summary (e.g. "Kernthemen:") if para.endswith(":") and len(para) < 50: pdf.ln(2) pdf.set_font("Noto", "B", 10) pdf.set_text_color(*C_BODY) pdf.multi_cell(body_w, 6, para) pdf.set_font("Noto", "", 10) pdf.set_text_color(*C_SUMMARY) else: pdf.multi_cell(body_w, 6, para) pdf.ln(10) # ── TRANSCRIPT SECTION ──────────────────────────────────────────────────── pdf.set_font("Noto", "B", 8) pdf.set_text_color(*C_LABEL) pdf.set_x(pdf.l_margin) pdf.cell(0, 5, "TRANSKRIPT", new_x="LMARGIN", new_y="NEXT") pdf.set_draw_color(*C_RULE) pdf.set_line_width(0.3) pdf.line(pdf.l_margin, pdf.get_y() + 1, pdf.w - pdf.r_margin, pdf.get_y() + 1) pdf.ln(6) for line in transcript.split("\n"): if not line.strip(): pdf.ln(2) continue colon = line.find(":") if colon == -1: pdf.set_font("Noto", "", 10) pdf.set_text_color(*C_BODY) pdf.set_x(pdf.l_margin) pdf.multi_cell(pdf.w - pdf.l_margin - pdf.r_margin, 6, line.strip()) pdf.ln(1) continue speaker = line[:colon].strip() text = line[colon + 1 :].strip() if not text: continue # Speaker name bold, then text inline — no colour, no background pdf.set_x(pdf.l_margin) pdf.set_font("Noto", "B", 10) pdf.set_text_color(*C_BODY) name_label = speaker + ": " name_w = pdf.get_string_width(name_label) y = pdf.get_y() pdf.set_xy(pdf.l_margin, y) pdf.cell(name_w, 5, name_label) text_x = pdf.l_margin + name_w text_w = pdf.w - pdf.r_margin - text_x pdf.set_font("Noto", "", 10) pdf.set_xy(text_x, y) pdf.multi_cell(text_w, 5, text) pdf.ln(0.5) return bytes(pdf.output()) # ── Email / file output ──────────────────────────────────────────────────────── def deliver( to_email: str, title: str, summary: str, transcript: str, ) -> bool: """Erstellt PDF und sendet es per E-Mail oder speichert es als Datei.""" pdf_bytes = build_pdf(title, summary, transcript) safe_title = _safe_filename(title) date_str = datetime.now().strftime("%Y-%m-%d") pdf_name = f"{date_str}_{safe_title}.pdf" smtp_host = os.getenv("SMTP_HOST") smtp_port = int(os.getenv("SMTP_PORT", 587)) smtp_user = os.getenv("SMTP_USER") smtp_password = os.getenv("SMTP_PASSWORD") # SMTP_FROM kann ein Anzeigename sein — wir stellen sicher dass eine # gültige Absenderadresse verwendet wird: "Name " oder nur die E-Mail smtp_from_raw = os.getenv("SMTP_FROM", "").strip() if smtp_from_raw and "@" not in smtp_from_raw and smtp_user: smtp_from = f"{smtp_from_raw} <{smtp_user}>" elif smtp_from_raw: smtp_from = smtp_from_raw else: smtp_from = smtp_user or "" if not all([smtp_host, smtp_user, smtp_password]): # Fallback: Datei speichern out = OUTPUT_DIR / pdf_name out.write_bytes(pdf_bytes) print(f"SMTP nicht konfiguriert – PDF gespeichert: {out}") return True body = ( f"Meeting: {title}\n" f"Datum: {datetime.now().strftime('%d.%m.%Y')}\n\n" f"--- Zusammenfassung ---\n\n{summary}\n\n" f"Das vollständige Transkript ist als PDF angehängt." ) msg = MIMEMultipart() msg["Subject"] = f"Meeting-Protokoll: {title}" msg["From"] = smtp_from msg["To"] = to_email msg.attach(MIMEText(body, "plain", "utf-8")) attachment = MIMEApplication(pdf_bytes, _subtype="pdf") attachment.add_header("Content-Disposition", "attachment", filename=pdf_name) msg.attach(attachment) with smtplib.SMTP(str(smtp_host), smtp_port) as server: server.starttls() server.login(str(smtp_user), str(smtp_password)) server.send_message(msg) return True # ── API Endpoints ────────────────────────────────────────────────────────────── @app.get("/", response_class=HTMLResponse) async def get_index(): return Path(__file__).parent.joinpath("index.html").read_text(encoding="utf-8") @app.post("/upload") async def upload_audio(file: UploadFile = File(...)): content_type = file.content_type or "" if content_type not in ALLOWED_CONTENT_TYPES and not content_type.startswith( "audio/" ): raise HTTPException( status_code=415, detail=f"Nicht unterstützter Typ: {content_type}" ) content = await file.read() if len(content) > MAX_FILE_SIZE: raise HTTPException( status_code=413, detail=f"Datei zu groß ({len(content) // 1024 // 1024} MB). Max 25 MB", ) if len(content) == 0: raise HTTPException(status_code=400, detail="Leere Datei") ext_map = { "audio/mpeg": ".mp3", "audio/mp3": ".mp3", "audio/webm": ".webm", "video/webm": ".webm", "audio/ogg": ".ogg", "audio/wav": ".wav", "audio/flac": ".flac", } ext = ext_map.get(content_type, ".audio") file_id = str(uuid.uuid4()) file_path = DATA_DIR / f"{file_id}{ext}" file_path.write_bytes(content) return {"file_id": file_id, "filename": file.filename, "size": len(content)} @app.post("/transcribe") async def transcribe(body: dict = Body(...)): file_id = body.get("file_id") if not file_id: raise HTTPException(status_code=400, detail="file_id required") matches = [m for m in DATA_DIR.glob(f"{file_id}.*") if ".converted." not in m.name] if not matches: raise HTTPException(status_code=404, detail="Datei nicht gefunden") try: text = transcribe_audio(matches[0]) except Exception as e: raise HTTPException( status_code=500, detail=f"Transkription fehlgeschlagen: {e}" ) return {"transcription": text} @app.post("/process") async def process_text(text: str = Form(...)): if not text.strip(): raise HTTPException(status_code=400, detail="Kein Text") try: return {"processed": process_with_ai(text)} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.post("/summarize") async def summarize(transcript: str = Form(...), title: str = Form(...)): """Erstellt eine KI-Zusammenfassung des finalen Transkripts.""" if not transcript.strip(): raise HTTPException(status_code=400, detail="Kein Transkript") try: return {"summary": summarize_with_ai(transcript, title or "Meeting")} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.post("/send") async def send( email: str = Form(...), title: str = Form(...), summary: str = Form(...), transcript: str = Form(...), ): """Generiert PDF und sendet/speichert es.""" if not email or "@" not in email: raise HTTPException(status_code=400, detail="Ungültige E-Mail") try: deliver(email, title or "Meeting", summary, transcript) return {"success": True} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) if __name__ == "__main__": import uvicorn port = int(os.getenv("PORT", "8276")) uvicorn.run(app, host="0.0.0.0", port=port)