added self sign up and email verification

This commit is contained in:
RubenRWU
2026-03-09 13:31:30 +01:00
commit f38dde8188
6 changed files with 2119 additions and 0 deletions

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
OPENAI_API_KEY=your_openai_api_key_here
HF_TOKEN=your_huggingface_token_here
ENABLE_SPEAKER_DIARIZATION=false
SMTP_HOST=smtp.ionos.de
SMTP_PORT=587
SMTP_USER=ruben.fischer@terrarum.de
SMTP_PASSWORD=ALF_1123RuFi
SMTP_FROM=Onyva LinkedIn Post System

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
# Virtual Environment
.venv/
venv/
env/
# Environment / Secrets
.env
# Projektdaten (Uploads & Transkripte mit persönlichen Daten)
data/
output/
# Große Binärdateien
*.tar.xz
*.tar.gz
*.zip
# IDE
.idea/
.vscode/
*.iml
# Linter / Tool Caches
.ruff_cache/
.mypy_cache/
.pytest_cache/
# Build
dist/
build/
*.egg-info/

140
SPEC.md Normal file
View File

@@ -0,0 +1,140 @@
# Transcribe App Specification
## Project Overview
- **Name**: Transcribe App
- **Type**: Web Application (FastAPI + Vanilla JS)
- **Core Functionality**: Voice recording/transcription with AI processing and email delivery
- **Target Users**: Users who need to transcribe voice notes and receive processed text via email
## Tech Stack
- **Backend**: FastAPI (Python)
- **Speech-to-Text**: OpenAI Whisper API
- **AI Processing**: OpenAI GPT-4o
- **Email**: SMTP with environment variables
- **Frontend**: Vanilla HTML/CSS/JS
## UI/UX Specification
### Layout Structure
- Single page application
- Centered content container (max-width: 600px)
- Sections: Header, Upload/Record area, Email input, Status display
### Visual Design
#### Color Palette
- **Background**: #000000 (black)
- **Primary Text**: #FFFFFF (white)
- **Secondary Text**: #888888 (gray)
- **Accent**: #FFFFFF (white)
- **Border**: #333333 (dark gray)
- **Hover/Active**: #1A1A1A (near black)
- **Success**: #00FF00 (green)
- **Error**: #FF0000 (red)
#### Typography
- **Font Family**: "JetBrains Mono", monospace (Google Fonts)
- **Heading**: 24px, bold
- **Body**: 14px, regular
- **Labels**: 12px, uppercase, letter-spacing: 2px
#### Spacing
- Container padding: 40px
- Section gap: 30px
- Element gap: 16px
### Components
#### Drop Zone
- Dashed border (#333333)
- Height: 200px
- Border radius: 8px
- States: default, hover (border: #FFFFFF), dragover (background: #1A1A1A)
- Icon: microphone SVG
- Text: "MP3 hierher ziehen oder klicken zum Hochladen"
#### Record Button
- Circular, 80px diameter
- White border, transparent background
- Microphone icon inside
- States: default, recording (pulsing red animation), disabled
- Text below: "Aufnahme starten" / "Aufnahme stoppen"
#### Email Input
- Full width text input
- Black background, white border
- Placeholder: "E-MAIL-ADRESSE"
- Email validation indicator
#### Process Indicator
- Steps displayed vertically:
1. "Hochladen..." / "✓ Hochgeladen"
2. "Transkribieren..." / "✓ Transkribiert"
3. "KI-Verarbeitung..." / "✓ Verarbeitet"
4. "E-Mail senden..." / "✓ Gesendet"
- Each step with icon, timestamp
#### Send Button
- Full width, white background, black text
- Height: 48px
- States: default, hover (gray #CCCCCC), disabled, loading
## Functionality Specification
### Core Features
#### 1. File Upload (Drag & Drop)
- Accept only .mp3 files
- Max file size: 25MB
- Show progress during upload
- Validate file type before upload
#### 2. Voice Recording
- Use MediaRecorder API (browser)
- Record in webm/opus format (convert to mp3 on server)
- Show recording duration timer
- Playback recorded audio before submission
#### 3. Transcription
- Use OpenAI Whisper API
- Support German language
- Return timestamped segments
#### 4. AI Processing
- Use OpenAI GPT-4o
- Improve transcription:
- Fix grammar and spelling
- Add proper punctuation
- Structure into paragraphs
- Maintain original meaning
#### 5. Email Delivery
- Send processed text via SMTP
- Include original and processed text
- Email format: plain text
### API Endpoints
- `POST /upload` - Upload audio file
- `POST /transcribe` - Transcribe audio
- `POST /process` - AI process text
- `POST /send-email` - Send email
### Environment Variables
- OPENAI_API_KEY
- SMTP_HOST
- SMTP_PORT
- SMTP_USER
- SMTP_PASSWORD
- SMTP_FROM
## Acceptance Criteria
1. ✓ User can drag & drop MP3 file onto drop zone
2. ✓ User can click to browse and select MP3 file
3. ✓ User can start and stop voice recording
4. ✓ User can play back recording before submitting
5. ✓ User can enter email address
6. ✓ App shows step-by-step progress
7. ✓ Final email is sent with processed text
8. ✓ Clean black/white design throughout
9. ✓ Responsive on mobile devices

1330
index.html Normal file

File diff suppressed because it is too large Load Diff

594
main.py Normal file
View File

@@ -0,0 +1,594 @@
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 <email>" 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
uvicorn.run(app, host="0.0.0.0", port=8000)

9
requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
fastapi==0.135.1
openai==2.24.0
python-multipart==0.0.22
uvicorn==0.41.0
whisperx==3.8.1
torch==2.8.0
pyannote.audio>=3.3
python-dotenv==1.2.2
fpdf2