{"repo": "armpro24-blip/meetingbro", "files": {"app/backend/meetingbro/main.py": {"a7dd106ee474544a47a9637ca7f9449ac1086a13": {"t": 1785674304.4534297, "n": 65390, "h": "d45bf15dc6369de1", "c": "from __future__ import annotations\n\nimport asyncio\nimport json\nimport logging\nimport os\nimport uuid\nfrom contextlib import asynccontextmanager\nfrom datetime import datetime, timezone\nfrom pathlib import Path\nfrom typing import Optional\n\nimport uvicorn\nfrom fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom pydantic import BaseModel\n\nfrom .asr.base import ASRAdapter\nfrom .asr.faster_whisper_adapter import FasterWhisperAdapter\nfrom .audio import AudioSource, MicrophoneSource, MixedAudioSource, SystemAudioLoopbackSource, WavFileSource\nfrom .audio.devices import enumerate_audio_devices\nfrom .exporter import export_meeting\nfrom .hardware import HardwareProfile, detect_hardware_profile\nfrom .llm.openai_compatible import OpenAICompatibleClient, OpenAICompatibleConfig, _load_dotenv_if_present\nfrom .schemas import (\n CreateNoteRequest,\n ErrorPayload,\n ExportMeetingRequest,\n ExportMeetingResponse,\n LanguageCode,\n MeetingListItem,\n Note,\n SessionStatePayload,\n SummarySnapshot,\n TranscriptSegment,\n)\nfrom .session.manager import SessionConfig, SessionManager\nfrom .session.profiles import DEFAULT_RUNTIME_PROFILE, normalize_runtime_profile, runtime_profile_defaults\nfrom .storage.db import Storage\nfrom .summarization.llm import LLMSummarizer\nfrom .translation.llm import LLMTranslator\n\nlogger = logging.getLogger(\"meetingbro\")\nlogging.basicConfig(level=logging.INFO, format=\"%(asctime)s %(levelname)s %(name)s: %(message)s\")\n\n\nPROJECT_ROOT = Path(__file__).resolve().parents[3]\nDEFAULT_DB_PATH = PROJECT_ROOT / \"data\" / \"meetingbro.db\"\nDEFAULT_EXPORT_ROOT = PROJECT_ROOT / \"exports\"\nDEFAULT_DIARIZATION_MODEL = (\n PROJECT_ROOT / \"models\" / \"speaker-embedding\" / \"3dspeaker_campplus_sv_zh-cn_16k-common.onnx\"\n)\nDEFAULT_PREVIEW_QWEN_DIR = PROJECT_ROOT / \"models\" / \"sherpa-onnx-qwen3-asr-0.6B-int8-2026-03-25\"\n\n\ndef _resolve_qwen_model_dir() -> Path:\n \"\"\"Resolve the Qwen3 preview model dir (env override or the bundled default).\"\"\"\n env = os.environ.get(\"MEETINGBRO_PREVIEW_QWEN3_MODEL_DIR\", \"\").strip()\n if not env:\n return DEFAULT_PREVIEW_QWEN_DIR\n path = Path(env)\n return path if path.is_absolute() else PROJECT_ROOT / path\n\n\ndef _recommended_asr_executor_workers() -> int:\n # SessionManager drives at most one ASR call at a time per session, so more\n # than one ASR worker per session adds thread overhead without throughput gain.\n return 1\n\n\ndef _recommended_preview_asr_executor_workers() -> int:\n return 1\n\n\ndef _build_diarizer(hardware: HardwareProfile | None):\n \"\"\"Build the speaker diarizer, or None when disabled (the default).\n\n Speaker diarization is opt-in (MEETINGBRO_DIARIZATION_ENABLED). The neural\n backend uses a sherpa-onnx speaker-embedding model for reliable cross-window\n identity; if the model is missing or fails to load it falls back to the\n dependency-free energy heuristic so a session never breaks over diarization.\n \"\"\"\n if not _env_bool(\"MEETINGBRO_DIARIZATION_ENABLED\", False):\n return None\n\n backend = os.environ.get(\"MEETINGBRO_DIARIZATION_BACKEND\", \"neural\").strip().lower()\n if backend == \"neural\":\n model = os.environ.get(\"MEETINGBRO_DIARIZATION_MODEL\", \"\").strip() or str(DEFAULT_DIARIZATION_MODEL)\n if Path(model).exists():\n try:\n from .diarization.neural import NeuralDiarizer\n\n diarizer = NeuralDiarizer(\n model_path=model,\n threshold=_env_float(\"MEETINGBRO_DIARIZATION_THRESHOLD\", 0.5),\n num_threads=_env_int(\"MEETINGBRO_DIARIZATION_NUM_THREADS\", 1),\n provider=_env_str_auto(\"MEETINGBRO_DIARIZATION_PROVIDER\", \"cpu\"),\n )\n diarizer.ensure_loaded()\n logger.info(\"speaker diarization enabled (neural, model=%s)\", Path(model).name)\n return diarizer\n except Exception as exc:\n logger.warning(\"neural diarizer load failed (%s); falling back to energy\", exc)\n else:\n logger.warning(\n \"diarization model not found at %s; falling back to energy diarizer\", model\n )\n\n from .diarization.energy import EnergyDiarizer\n\n logger.info(\"speaker diarization enabled (energy heuristic)\")\n return EnergyDiarizer()\n\n\ndef _recommended_summary_executor_workers() -> int:\n cores = os.cpu_count() or 4\n if cores >= 12:\n return 2\n return 1\n\n\ndef _recommended_translation_executor_workers() -> int:\n return 2\n\n\ndef _env_float(name: str, default: float) -> float:\n value = os.environ.get(name)\n if value is None:\n return default\n try:\n return float(value)\n except ValueError:\n logger.warning(\"invalid %s=%r, using default %.3f\", name, value, default)\n return default\n\n\ndef _env_int(name: str, default: int) -> int:\n value = os.environ.get(name)\n if value is None:\n return default\n try:\n return int(value)\n except ValueError:\n logger.warning(\"invalid %s=%r, using default %d\", name, value, default)\n return default\n\n\ndef _env_bool(name: str, default: bool) -> bool:\n value = os.environ.get(name)\n if value is None:\n return default\n normalized = value.strip().lower()\n if normalized in {\"1\", \"true\", \"yes\", \"on\"}:\n return True\n if normalized in {\"0\", \"false\", \"no\", \"off\"}:\n return False\n logger.warning(\"invalid %s=%r, using default %s\", name, value, default)\n return default\n\n\ndef _env_str_auto(name: str, default: str) -> str:\n value = os.environ.get(name)\n if value is None:\n return default\n normalized = value.strip()\n if not normalized or normalized.lower() == \"auto\":\n return default\n return normalized\n\n\ndef _profile_value(profile: dict[str, object], key: str, env_name: str, default: object) -> object:\n if env_name in os.environ:\n return os.environ[env_name]\n return profile.get(key, default)\n\n\ndef _profile_float(profile: dict[str, object], key: str, env_name: str, default: float) -> float:\n value = _profile_value(profile, key, env_name, default)\n try:\n return float(value)\n except (TypeError, ValueError):\n logger.warning(\"invalid %s/%s=%r, using default %.3f\", key, env_name, value, default)\n return default\n\n\ndef _profile_bool(profile: dict[str, object], key: str, env_name: str, default: bool) -> bool:\n value = _profile_value(profile, key, env_name, default)\n if isinstance(value, bool):\n return value\n normalized = str(value).strip().lower()\n if normalized in {\"1\", \"true\", \"yes\", \"on\"}:\n return True\n if normalized in {\"0\", \"false\", \"no\", \"off\"}:\n return False\n logger.warning(\"invalid %s/%s=%r, using default %s\", key, env_name, value, default)\n return default\n\n\ndef _runtime_settings_from_profile(profile_name: str) -> dict[str, object]:\n profile = runtime_profile_defaults(profile_name)\n return {\n \"asr_accumulation_seconds\": _profile_float(profile, \"asr_accumulation_seconds\", \"MEETINGBRO_ASR_ACCUM_SECONDS\", 1.5),\n \"asr_early_flush_enabled\": _profile_bool(profile, \"asr_early_flush_enabled\", \"MEETINGBRO_ASR_EARLY_FLUSH_ENABLED\", True),\n \"asr_early_flush_min_seconds\": _profile_float(profile, \"asr_early_flush_min_seconds\", \"MEETINGBRO_ASR_EARLY_FLUSH_MIN_SECONDS\", 0.8),\n \"silence_commit_min_confidence\": _profile_float(profile, \"silence_commit_min_confidence\", \"MEETINGBRO_SILENCE_COMMIT_MIN_CONFIDENCE\", 0.75),\n \"silence_commit_min_duration_seconds\": _profile_float(profile, \"silence_commit_min_duration_seconds\", \"MEETINGBRO_SILENCE_COMMIT_MIN_DURATION_SECONDS\", 0.6),\n \"pre_vad_enabled\": _profile_bool(profile, \"pre_vad_enabled\", \"MEETINGBRO_PRE_VAD_ENABLED\", True),\n \"pre_vad_trailing_silence_seconds\": _profile_float(profile, \"pre_vad_trailing_silence_seconds\", \"MEETINGBRO_PRE_VAD_TRAILING_SILENCE_SECONDS\", 0.45),\n \"pre_vad_adaptive_trailing_silence_enabled\": _profile_bool(profile, \"pre_vad_adaptive_trailing_silence_enabled\", \"MEETINGBRO_PRE_VAD_ADAPTIVE_TRAILING_SILENCE_ENABLED\", True),\n \"pre_vad_adaptive_fast_trailing_silence_seconds\": _profile_float(profile, \"pre_vad_adaptive_fast_trailing_silence_seconds\", \"MEETINGBRO_PRE_VAD_ADAPTIVE_FAST_TRAILING_SILENCE_SECONDS\", 0.30),\n \"language_lock_enabled\": _profile_bool(profile, \"language_lock_enabled\", \"MEETINGBRO_LANGUAGE_LOCK_ENABLED\", False),\n \"weak_speech_rescue_enabled\": _profile_bool(profile, \"weak_speech_rescue_enabled\", \"MEETINGBRO_WEAK_SPEECH_RESCUE_ENABLED\", True),\n \"weak_speech_rescue_fast_window_seconds\": _profile_float(profile, \"weak_speech_rescue_fast_window_seconds\", \"MEETINGBRO_WEAK_SPEECH_RESCUE_FAST_WINDOW_SECONDS\", 2.5),\n \"resource_governor_policy\": str(_profile_value(profile, \"resource_governor_policy\", \"MEETINGBRO_RESOURCE_GOVERNOR_POLICY\", \"balanced\")),\n \"resource_pressure_rtf_threshold\": _profile_float(profile, \"resource_pressure_rtf_threshold\", \"MEETINGBRO_RESOURCE_PRESSURE_RTF_THRESHOLD\", 0.9),\n \"resource_critical_rtf_threshold\": _profile_float(profile, \"resource_critical_rtf_threshold\", \"MEETINGBRO_RESOURCE_CRITICAL_RTF_THRESHOLD\", 1.25),\n \"resource_pressure_backlog_seconds\": _profile_float(profile, \"resource_pressure_backlog_seconds\", \"MEETINGBRO_RESOURCE_PRESSURE_BACKLOG_SECONDS\", 3.0),\n \"resource_critical_backlog_seconds\": _profile_float(profile, \"resource_critical_backlog_seconds\", \"MEETINGBRO_RESOURCE_CRITICAL_BACKLOG_SECONDS\", 6.0),\n \"rolling_window_seconds\": _profile_float(profile, \"rolling_window_seconds\", \"MEETINGBRO_ROLLING_WINDOW_SECONDS\", 180.0),\n \"summary_tail_seconds\": _profile_float(profile, \"summary_tail_seconds\", \"MEETINGBRO_SUMMARY_TAIL_SECONDS\", 120.0),\n \"rolling_interval_seconds\": _profile_float(profile, \"rolling_interval_seconds\", \"MEETINGBRO_ROLLING_INTERVAL_SECONDS\", 60.0),\n \"memory_interval_seconds\": _profile_float(profile, \"memory_interval_seconds\", \"MEETINGBRO_MEMORY_INTERVAL_SECONDS\", 120.0),\n \"cumulative_interval_seconds\": _profile_float(profile, \"cumulative_interval_seconds\", \"MEETINGBRO_CUMULATIVE_INTERVAL_SECONDS\", 180.0),\n \"refinement_interval_seconds\": _profile_float(profile, \"refinement_interval_seconds\", \"MEETINGBRO_REFINEMENT_INTERVAL_SECONDS\", 180.0),\n \"min_segments_for_rolling\": int(_profile_value(profile, \"min_segments_for_rolling\", \"MEETINGBRO_MIN_SEGMENTS_FOR_ROLLING\", 1)),\n \"min_segments_for_memory\": int(_profile_value(profile, \"min_segments_for_memory\", \"MEETINGBRO_MIN_SEGMENTS_FOR_MEMORY\", 3)),\n \"min_segments_for_cumulative\": int(_profile_value(profile, \"min_segments_for_cumulative\", \"MEETINGBRO_MIN_SEGMENTS_FOR_CUMULATIVE\", 3)),\n \"min_segments_for_refinement\": int(_profile_value(profile, \"min_segments_for_refinement\", \"MEETINGBRO_MIN_SEGMENTS_FOR_REFINEMENT\", 2)),\n \"live_translation_backfill_limit\": int(_profile_value(profile, \"live_translation_backfill_limit\", \"MEETINGBRO_LIVE_TRANSLATION_BACKFILL_LIMIT\", 20)),\n \"live_translation_max_pending\": int(_profile_value(profile, \"live_translation_max_pending\", \"MEETINGBRO_LIVE_TRANSLATION_MAX_PENDING\", 12)),\n \"live_translation_safeguard_max_pending\": int(_profile_value(profile, \"live_translation_safeguard_max_pending\", \"MEETINGBRO_LIVE_TRANSLATION_SAFEGUARD_MAX_PENDING\", 4)),\n \"fast_preview_enabled\": _profile_bool(profile, \"fast_preview_enabled\", \"MEETINGBRO_FAST_PREVIEW_ENABLED\", True),\n }\n\n\ndef _chunk_seconds_for_profile(profile_name: str) -> float:\n profile = runtime_profile_defaults(profile_name)\n return _profile_float(profile, \"chunk_seconds\", \"MEETINGBRO_CHUNK_SECONDS\", 0.5)\n\n\ndef _language_lock_enabled_for_speech(forced_language: Optional[str]) -> bool:\n return forced_language in {\"zh\", \"en\", \"de\"}\n\n\ndef _resolve_preview_runtime(\n *,\n profile_settings: dict[str, object],\n preview_asr: Optional[ASRAdapter],\n preview_backend_name: str,\n preview_device: Optional[str],\n formal_device: str,\n) -> tuple[Optional[ASRAdapter], str, Optional[str]]:\n if bool(profile_settings.get(\"fast_preview_enabled\", True)):\n return preview_asr, preview_backend_name, preview_device\n return None, \"shared\", formal_device\n\n\ndef _configured_preview_runtime(\n hardware: HardwareProfile | None,\n *,\n formal_device: str,\n) -> tuple[str, str]:\n preview_backend = os.environ.get(\"MEETINGBRO_PREVIEW_ASR_BACKEND\", \"\").strip().lower()\n if preview_backend == \"qwen3\":\n # Guard: the Qwen model is optional (~700 MB, not auto-downloaded). If it\n # is missing, degrade to a shared (formal-model) preview instead of\n # failing every preview decode — so enabling preview by default is safe.\n model_dir = _resolve_qwen_model_dir()\n if not model_dir.exists():\n logger.warning(\n \"Qwen3 preview backend selected but model dir not found (%s); \"\n \"using shared preview instead\",\n model_dir,\n )\n return \"shared\", formal_device\n return (\n \"qwen3\",\n _env_str_auto(\n \"MEETINGBRO_PREVIEW_QWEN3_PROVIDER\",\n hardware.recommended_qwen_provider if hardware is not None else \"cpu\",\n ),\n )\n\n if preview_backend == \"whisper-streaming\":\n return \"whisper-streaming\", formal_device\n\n # Auto-default: when no backend is explicitly set (or set to \"auto\"), prefer\n # whisper-streaming if the Qwen model dir does not exist (Whisper-only majority).\n # When the Qwen model dir exists, keep qwen3 as the default for performance users.\n if not preview_backend or preview_backend == \"auto\":\n model_dir = _resolve_qwen_model_dir()\n if not model_dir.exists():\n logger.info(\n \"auto preview backend: Qwen model dir not found (%s); defaulting to whisper-streaming\",\n model_dir,\n )\n return \"whisper-streaming\", formal_device\n # Qwen model exists — keep qwen3 as default\n return (\n \"qwen3\",\n _env_str_auto(\n \"MEETINGBRO_PREVIEW_QWEN3_PROVIDER\",\n hardware.recommended_qwen_provider if hardware is not None else \"cpu\",\n ),\n )\n\n preview_size = os.environ.get(\"MEETINGBRO_PREVIEW_WHISPER_SIZE\", \"\").strip()\n if not preview_size or preview_size.lower() == \"shared\":\n return \"shared\", formal_device\n\n preview_default_device = \"cpu\"\n if hardware is not None and hardware.recommended_whisper_device != \"cuda\":\n preview_default_device = hardware.recommended_whisper_device\n return (\n \"faster_whisper\",\n _env_str_auto(\"MEETINGBRO_PREVIEW_WHISPER_DEVICE\", preview_default_device),\n )\n\n\ndef _should_load_preview_asr(\n *,\n profile_settings: dict[str, object],\n preview_backend_name: str,\n) -> bool:\n return bool(profile_settings.get(\"fast_preview_enabled\", True)) and preview_backend_name != \"shared\"\n\n\ndef _enforce_performance_requires_qwen(\n profile_name: str,\n *,\n preview_backend_name: str,\n) -> tuple[str, Optional[str]]:\n if profile_name == \"performance\" and preview_backend_name != \"qwen3\":\n return (\n \"balanced\",\n \"performance mode requires Qwen preview backend; switched to Balanced\",\n )\n return profile_name, None\n\n\ndef _maybe_start_preview_prewarm(\n app: FastAPI,\n *,\n preview_asr: Optional[ASRAdapter],\n preview_backend_name: str,\n profile_name: str,\n reason: str,\n) -> None:\n if not (\n preview_backend_name == \"qwen3\"\n and preview_asr is not None\n and _env_bool(\"MEETINGBRO_PREVIEW_QWEN3_PREWARM\", True)\n and hasattr(preview_asr, \"prewarm\")\n ):\n app.state.preview_asr_prewarm_future = None\n return\n\n loop = asyncio.get_running_loop()\n prewarm_future = loop.run_in_executor(None, preview_asr.prewarm) # type: ignore[attr-defined]\n\n def _log_preview_prewarm_done(task) -> None:\n try:\n task.result()\n except Exception as exc:\n logger.warning(\"Qwen3 preview ASR prewarm failed: %s\", exc)\n else:\n logger.info(\"Qwen3 preview ASR prewarm completed\")\n\n prewarm_future.add_done_callback(_log_preview_prewarm_done)\n app.state.preview_asr_prewarm_future = prewarm_future\n logger.info(\n \"starting Qwen3 preview ASR prewarm reason=%s runtime_profile=%s\",\n reason,\n profile_name,\n )\n\n\nasync def _ensure_preview_asr_loaded(app: FastAPI, *, profile_name: str) -> None:\n if getattr(app.state, \"preview_asr\", None) is not None:\n return\n if getattr(app.state, \"preview_asr_backend_name\", \"shared\") == \"shared\":\n return\n\n async with app.state.preview_asr_build_lock:\n if getattr(app.state, \"preview_asr\", None) is not None:\n return\n\n preview_asr = _build_preview_asr(\n getattr(app.state, \"hardware_profile\", None),\n formal_asr=getattr(app.state, \"asr\", None),\n )\n app.state.preview_asr = preview_asr\n if preview_asr is None:\n app.state.preview_asr_backend_name = \"shared\"\n app.state.preview_asr_device = getattr(app.state, \"formal_asr_device\", \"cpu\")\n app.state.preview_asr_prewarm_future = None\n return\n\n logger.info(\n \"loaded preview ASR on demand runtime_profile=%s backend=%s device=%s\",\n profile_name,\n app.state.preview_asr_backend_name,\n app.state.preview_asr_device,\n )\n _maybe_start_preview_prewarm(\n app,\n preview_asr=preview_asr,\n preview_backend_name=app.state.preview_asr_backend_name,\n profile_name=profile_name,\n reason=\"on_demand\",\n )\n\n\ndef _build_streaming_preview_adapter(formal_asr: ASRAdapter) -> \"ASRAdapter\":\n \"\"\"Build the whisper-streaming preview lane, sharing the formal model.\"\"\"\n from .asr.streaming_whisper_adapter import StreamingWhisperAdapter\n\n return StreamingWhisperAdapter(formal_asr)\n\n\ndef _build_preview_asr(hardware: HardwareProfile | None = None, formal_asr: Optional[ASRAdapter] = None) -> ASRAdapter | None:\n backend = os.environ.get(\"MEETINGBRO_PREVIEW_ASR_BACKEND\", \"\").strip().lower()\n\n if backend == \"whisper-streaming\":\n if formal_asr is None:\n logger.warning(\n \"whisper-streaming preview backend requested but formal_asr not provided; \"\n \"using shared preview instead\"\n )\n return None\n return _build_streaming_preview_adapter(formal_asr)\n\n if backend == \"qwen3\":\n # Lazy import so that a missing sherpa-onnx never affects normal startup.\n from .asr.qwen3_asr_adapter import Qwen3ASRAdapter\n\n # Resolve relative paths against the project root so the backend can\n # be started from any working directory (env override or bundled default).\n model_dir_path = _resolve_qwen_model_dir()\n return Qwen3ASRAdapter(\n model_dir=model_dir_path,\n num_threads=_env_int(\n \"MEETINGBRO_PREVIEW_QWEN3_NUM_THREADS\",\n hardware.recommended_qwen_threads if hardware is not None else 2,\n ),\n provider=_env_str_auto(\n \"MEETINGBRO_PREVIEW_QWEN3_PROVIDER\",\n hardware.recommended_qwen_provider if hardware is not None else \"cpu\",\n ),\n max_total_len=_env_int(\"MEETINGBRO_PREVIEW_QWEN3_MAX_TOTAL_LEN\", 256),\n max_new_tokens=_env_int(\"MEETINGBRO_PREVIEW_QWEN3_MAX_NEW_TOKENS\", 96),\n filter_language_script=_env_bool(\n \"MEETINGBRO_PREVIEW_QWEN3_FILTER_LANGUAGE_SCRIPT\", True\n ),\n suppress_fillers=_env_bool(\"MEETINGBRO_PREVIEW_QWEN3_SUPPRESS_FILLERS\", True),\n )\n\n # Default: dedicated faster-whisper model, or None → fall back to shared model.\n model_size = os.environ.get(\"MEETINGBRO_PREVIEW_WHISPER_SIZE\", \"\").strip()\n if not model_size or model_size.lower() == \"shared\":\n return None\n preview_default_device = \"cpu\"\n if hardware is not None and hardware.recommended_whisper_device != \"cuda\":\n preview_default_device = hardware.recommended_whisper_device\n preview_device = _env_str_auto(\"MEETINGBRO_PREVIEW_WHISPER_DEVICE\", preview_default_device)\n preview_compute_default = \"int8\" if preview_device == \"cpu\" else (\n hardware.recommended_whisper_compute_type if hardware is not None else \"float16\"\n )\n\n return FasterWhisperAdapter(\n model_size=model_size,\n device=preview_device,\n compute_type=_env_str_auto(\"MEETINGBRO_PREVIEW_WHISPER_COMPUTE_TYPE\", preview_compute_default),\n beam_size=_env_int(\"MEETINGBRO_PREVIEW_WHISPER_BEAM_SIZE\", 1),\n cpu_threads=_env_int(\"MEETINGBRO_PREVIEW_WHISPER_CPU_THREADS\", 0),\n num_workers=_env_int(\"MEETINGBRO_PREVIEW_WHISPER_NUM_WORKERS\", 1),\n vad_threshold=_env_float(\n \"MEETINGBRO_PREVIEW_WHISPER_VAD_THRESHOLD\",\n _env_float(\"MEETINGBRO_WHISPER_VAD_THRESHOLD\", 0.3),\n ),\n vad_min_speech_ms=_env_int(\n \"MEETINGBRO_PREVIEW_WHISPER_VAD_MIN_SPEECH_MS\",\n _env_int(\"MEETINGBRO_WHISPER_VAD_MIN_SPEECH_MS\", 100),\n ),\n vad_min_silence_ms=_env_int(\n \"MEETINGBRO_PREVIEW_WHISPER_VAD_MIN_SILENCE_MS\",\n _env_int(\"MEETINGBRO_WHISPER_VAD_MIN_SILENCE_MS\", 300),\n ),\n vad_speech_pad_ms=_env_int(\n \"MEETINGBRO_PREVIEW_WHISPER_VAD_SPEECH_PAD_MS\",\n _env_int(\"MEETINGBRO_WHISPER_VAD_SPEECH_PAD_MS\", 400),\n ),\n multilingual=_env_bool(\"MEETINGBRO_PREVIEW_WHISPER_MULTILINGUAL\", True),\n language_detection_threshold=_env_float(\n \"MEETINGBRO_PREVIEW_WHISPER_LANGUAGE_DETECTION_THRESHOLD\",\n 0.5,\n ),\n language_detection_segments=_env_int(\n \"MEETINGBRO_PREVIEW_WHISPER_LANGUAGE_DETECTION_SEGMENTS\",\n 1,\n ),\n )\n\n\n@asynccontextmanager\nasync def lifespan(app: FastAPI):\n _load_dotenv_if_present()\n hardware = detect_hardware_profile()\n startup_runtime_profile = normalize_runtime_profile(\n os.environ.get(\"MEETINGBRO_RUNTIME_PROFILE\")\n or getattr(hardware, \"recommended_runtime_profile\", DEFAULT_RUNTIME_PROFILE)\n )\n startup_profile_settings = _runtime_settings_from_profile(startup_runtime_profile)\n storage = Storage(Path(os.environ.get(\"MEETINGBRO_DB\", str(DEFAULT_DB_PATH))))\n whisper_size = _env_str_auto(\"MEETINGBRO_WHISPER_SIZE\", hardware.recommended_whisper_size)\n whisper_device = _env_str_auto(\"MEETINGBRO_WHISPER_DEVICE\", hardware.recommended_whisper_device)\n whisper_compute_type = _env_str_auto(\"MEETINGBRO_WHISPER_COMPUTE_TYPE\", hardware.recommended_whisper_compute_type)\n asr = FasterWhisperAdapter(\n model_size=whisper_size,\n device=whisper_device,\n compute_type=whisper_compute_type,\n beam_size=_env_int(\"MEETINGBRO_WHISPER_BEAM_SIZE\", 1),\n cpu_threads=_env_int(\"MEETINGBRO_WHISPER_CPU_THREADS\", 0),\n num_workers=_env_int(\"MEETINGBRO_WHISPER_NUM_WORKERS\", 1),\n vad_threshold=_env_float(\"MEETINGBRO_WHISPER_VAD_THRESHOLD\", 0.3),\n vad_min_speech_ms=_env_int(\"MEETINGBRO_WHISPER_VAD_MIN_SPEECH_MS\", 100),\n vad_min_silence_ms=_env_int(\"MEETINGBRO_WHISPER_VAD_MIN_SILENCE_MS\", 300),\n vad_speech_pad_ms=_env_int(\"MEETINGBRO_WHISPER_VAD_SPEECH_PAD_MS\", 400),\n multilingual=_env_bool(\"MEETINGBRO_WHISPER_MULTILINGUAL\", False),\n language_detection_threshold=_env_float(\"MEETINGBRO_WHISPER_LANGUAGE_DETECTION_THRESHOLD\", 0.5),\n language_detection_segments=_env_int(\"MEETINGBRO_WHISPER_LANGUAGE_DETECTION_SEGMENTS\", 1),\n )\n _preview_asr_backend_name, _preview_asr_device = _configured_preview_runtime(\n hardware,\n formal_device=whisper_device,\n )\n preview_asr = None\n if _should_load_preview_asr(\n profile_settings=startup_profile_settings,\n preview_backend_name=_preview_asr_backend_name,\n ):\n preview_asr = _build_preview_asr(hardware, formal_asr=asr)\n app.state.storage = storage\n app.state.hardware_profile = hardware\n app.state.asr = asr\n app.state.formal_asr_device = whisper_device\n app.state.whisper_size = whisper_size\n app.state.whisper_compute_type = whisper_compute_type\n app.state.preview_asr = preview_asr\n app.state.preview_asr_backend_name = _preview_asr_backend_name\n app.state.preview_asr_device = _preview_asr_device\n app.state.preview_asr_build_lock = asyncio.Lock()\n # Speaker diarization is opt-in and shared across sessions (reset per session\n # in the WS handler); None when disabled.\n app.state.diarizer = _build_diarizer(hardware)\n # Readiness state surfaced via /health so the desktop app can show a\n # \"preparing speech model\" screen instead of a hung UI on first run (the\n # Whisper model downloads/loads lazily and can take minutes the first time).\n app.state.ready = False\n app.state.ready_phase = \"loading_model\"\n app.state.ready_detail = None\n if preview_asr is not None:\n _maybe_start_preview_prewarm(\n app,\n preview_asr=preview_asr,\n preview_backend_name=_preview_asr_backend_name,\n profile_name=startup_runtime_profile,\n reason=\"startup\",\n )\n else:\n if _preview_asr_backend_name != \"shared\" and not bool(startup_profile_settings.get(\"fast_preview_enabled\", True)):\n logger.info(\n \"skipping preview ASR load because runtime profile %s disables fast preview\",\n startup_runtime_profile,\n )\n app.state.preview_asr_prewarm_future = None\n logger.info(\n \"MeetingBro backend starting db=%s hardware=%s hardware_summary=%s whisper_size=%s whisper_device=%s compute=%s beam=%d cpu_threads=%d whisper_workers=%d preview_backend=%s preview_device=%s preview_compute=%s preview_asr_workers=%d preview_loaded=%s chunk=%.2fs accum=%.2fs silence_rms=%.4f audio_conditioning=%s denoise=%s pre_vad=%s pre_vad_conditioning=%s pre_vad_threshold=%.2f pre_vad_energy_rms=%.4f pre_vad_max=%.1fs weak_rescue=%s weak_rescue_rms=%.4f..%.4f language_lock=%s asr_retry=%s asr_safeguard=%s safeguard_rtf=%.2f suspicious_no_speech=%.2f suspicious_avg_logprob=%.2f suspicious_compression=%.2f mixed_mic_gain=%.2f mixed_system_gain=%.2f mixed_auto_balance=%s mixed_max_mic_boost=%.2f asr_workers=%d summary_workers=%d translation_workers=%d translation_backfill=%d translation_max_pending=%d\",\n storage._db_path,\n hardware.label,\n hardware.summary,\n whisper_size,\n whisper_device,\n whisper_compute_type,\n _env_int(\"MEETINGBRO_WHISPER_BEAM_SIZE\", 1),\n _env_int(\"MEETINGBRO_WHISPER_CPU_THREADS\", 0),\n _env_int(\"MEETINGBRO_WHISPER_NUM_WORKERS\", 1),\n _preview_asr_backend_name,\n _preview_asr_device,\n _env_str_auto(\"MEETINGBRO_PREVIEW_WHISPER_COMPUTE_TYPE\", \"int8\"),\n _env_int(\n \"MEETINGBRO_PREVIEW_ASR_EXECUTOR_WORKERS\",\n _recommended_preview_asr_executor_workers(),\n ),\n preview_asr is not None,\n _env_float(\"MEETINGBRO_CHUNK_SECONDS\", 0.5),\n _env_float(\"MEETINGBRO_ASR_ACCUM_SECONDS\", 1.5),\n _env_float(\"MEETINGBRO_SILENCE_RMS_THRESHOLD\", 0.002),\n _env_bool(\"MEETINGBRO_AUDIO_CONDITIONING_ENABLED\", True),\n _env_bool(\"MEETINGBRO_DENOISE_ENABLED\", False),\n _env_bool(\"MEETINGBRO_PRE_VAD_ENABLED\", True),\n _env_bool(\"MEETINGBRO_PRE_VAD_CONDITIONING_ENABLED\", True),\n _env_float(\"MEETINGBRO_PRE_VAD_THRESHOLD\", 0.38),\n _env_float(\"MEETINGBRO_PRE_VAD_ENERGY_RMS_THRESHOLD\", 0.005),\n _env_float(\"MEETINGBRO_PRE_VAD_MAX_SEGMENT_SECONDS\", 8.0),\n _env_bool(\"MEETINGBRO_WEAK_SPEECH_RESCUE_ENABLED\", True),\n _env_float(\"MEETINGBRO_WEAK_SPEECH_RESCUE_RMS_MIN\", 0.0008),\n _env_float(\"MEETINGBRO_WEAK_SPEECH_RESCUE_RMS_MAX\", 0.02),\n _env_bool(\"MEETINGBRO_LANGUAGE_LOCK_ENABLED\", False),\n _env_bool(\"MEETINGBRO_ASR_RETRY_ENABLED\", True),\n _env_bool(\"MEETINGBRO_ASR_SAFEGUARD_ENABLED\", True),\n _env_float(\"MEETINGBRO_ASR_SAFEGUARD_RTF_THRESHOLD\", 0.9),\n _env_float(\"MEETINGBRO_SUSPICIOUS_SEGMENT_NO_SPEECH_PROB\", 0.6),\n _env_float(\"MEETINGBRO_SUSPICIOUS_SEGMENT_AVG_LOGPROB\", -0.9),\n _env_float(\"MEETINGBRO_SUSPICIOUS_SEGMENT_COMPRESSION_RATIO\", 2.1),\n _env_float(\"MEETINGBRO_MIXED_MIC_GAIN\", 1.2),\n _env_float(\"MEETINGBRO_MIXED_SYSTEM_GAIN\", 1.0),\n _env_bool(\"MEETINGBRO_MIXED_AUTO_BALANCE_ENABLED\", True),\n _env_float(\"MEETINGBRO_MIXED_MAX_MIC_BOOST\", 1.8),\n _env_int(\"MEETINGBRO_ASR_EXECUTOR_WORKERS\", _recommended_asr_executor_workers()),\n _env_int(\"MEETINGBRO_SUMMARY_EXECUTOR_WORKERS\", _recommended_summary_executor_workers()),\n _env_int(\"MEETINGBRO_TRANSLATION_EXECUTOR_WORKERS\", _recommended_translation_executor_workers()),\n _env_int(\"MEETINGBRO_LIVE_TRANSLATION_BACKFILL_LIMIT\", 20),\n _env_int(\"MEETINGBRO_LIVE_TRANSLATION_MAX_PENDING\", 12),\n )\n async def _warm_up_formal_asr() -> None:\n loop = asyncio.get_running_loop()\n try:\n await loop.run_in_executor(None, asr.ensure_loaded)\n except Exception as exc: # noqa: BLE001 - surface any load/download failure\n app.state.ready_phase = \"error\"\n app.state.ready_detail = str(exc)\n logger.warning(\"formal ASR warm-up failed: %s\", exc)\n else:\n app.state.ready = True\n app.state.ready_phase = \"ready\"\n app.state.ready_detail = None\n logger.info(\"formal ASR ready (model=%s device=%s)\", whisper_size, whisper_device)\n\n warm_up_task = asyncio.create_task(_warm_up_formal_asr())\n try:\n yield\n finally:\n warm_up_task.cancel()\n try:\n await warm_up_task\n except (asyncio.CancelledError, Exception):\n pass\n storage.close()\n logger.info(\"MeetingBro backend stopping\")\n\n\napp = FastAPI(title=\"MeetingBro backend\", version=\"0.1.0\", lifespan=lifespan)\n\napp.add_middleware(\n CORSMiddleware,\n allow_origins=[\"*\"],\n allow_methods=[\"*\"],\n allow_headers=[\"*\"],\n)\n\n\n@app.get(\"/health\")\nasync def health() -> dict:\n # ``ready`` becomes true once the formal ASR model is materialized. ``phase``\n # is one of: loading_model | ready | error. The desktop supervisor keys its\n # startup gate off these fields (process-alive + ready), never a fixed timeout.\n return {\n \"status\": \"ok\",\n \"version\": \"0.1.0\",\n \"ready\": bool(getattr(app.state, \"ready\", False)),\n \"phase\": getattr(app.state, \"ready_phase\", \"loading_model\"),\n \"detail\": getattr(app.state, \"ready_detail\", None),\n }\n\n\ndef _mask_secret(value: Optional[str]) -> Optional[str]:\n if not value:\n return None\n tail = value[-4:] if len(value) >= 4 else value\n return f\"••••{tail}\"\n\n\n@app.get(\"/audio/devices\")\nasync def audio_devices() -> dict:\n # Read-only enumeration for the in-app device picker. Safe to call anytime.\n loop = asyncio.get_running_loop()\n return await loop.run_in_executor(None, enumerate_audio_devices)\n\n\n@app.get(\"/config\")\nasync def get_config() -> dict:\n # Effective startup config + hardware recommendations. Never returns the LLM\n # key value — only whether one is configured and a masked tail.\n hardware = getattr(app.state, \"hardware_profile\", None)\n llm_key = os.environ.get(\"MEETINGBRO_LLM_API_KEY\") or os.environ.get(\"LONGCAT_API_KEY\")\n return {\n \"whisper_size\": getattr(app.state, \"whisper_size\", \"auto\"),\n \"whisper_device\": getattr(app.state, \"formal_asr_device\", \"cpu\"),\n \"whisper_compute_type\": getattr(app.state, \"whisper_compute_type\", \"auto\"),\n \"preview_backend\": getattr(app.state, \"preview_asr_backend_name\", \"shared\"),\n \"port\": _env_int(\"MEETINGBRO_PORT\", 8765),\n \"cuda_available\": bool(getattr(hardware, \"ctranslate2_cuda_available\", False)),\n \"llm_configured\": bool(llm_key),\n \"llm_key_masked\": _mask_secret(llm_key),\n \"llm_model\": os.environ.get(\"MEETINGBRO_LLM_MODEL\"),\n \"llm_base_url\": os.environ.get(\"MEETINGBRO_LLM_BASE_URL\"),\n \"hardware\": {\n \"label\": getattr(hardware, \"label\", \"unknown\"),\n \"summary\": getattr(hardware, \"summary\", None),\n \"recommended_whisper_size\": getattr(hardware, \"recommended_whisper_size\", \"small\"),\n \"recommended_whisper_device\": getattr(hardware, \"recommended_whisper_device\", \"cpu\"),\n \"recommended_runtime_profile\": getattr(hardware, \"recommended_runtime_profile\", DEFAULT_RUNTIME_PROFILE),\n },\n }\n\n\nclass TestLlmRequest(BaseModel):\n api_key: str\n base_url: Optional[str] = None\n model: Optional[str] = None\n\n\n@app.post(\"/config/test-llm\")\nasync def test_llm(req: TestLlmRequest) -> dict:\n # Validate credentials with one tiny chat call. Does not touch global state\n # and never restarts the backend.\n api_key = (req.api_key or \"\").strip()\n if not api_key:\n return {\"ok\": False, \"error\": \"API key is required.\"}\n base_url = (req.base_url or \"https://api.openai.com/v1\").strip().rstrip(\"/\")\n model = (req.model or \"gpt-4o-mini\").strip()\n client = OpenAICompatibleClient(\n OpenAICompatibleConfig(api_key=api_key, base_url=base_url, model=model, timeout_seconds=20.0)\n )\n\n loop = asyncio.get_running_loop()\n start = datetime.now(timezone.utc)\n try:\n await loop.run_in_executor(\n None,\n lambda: client.chat(system=\"You are a connectivity check.\", user=\"Reply with OK.\", max_tokens=5),\n )\n except Exception as exc: # noqa: BLE001 - surface the provider error to the UI\n return {\"ok\": False, \"model\": model, \"error\": str(exc)}\n latency_ms = int((datetime.now(timezone.utc) - start).total_seconds() * 1000)\n return {\"ok\": True, \"model\": model, \"latency_ms\": latency_ms}\n\n\n@app.post(\"/notes\", response_model=Note)\nasync def create_note(req: CreateNoteRequest) -> Note:\n storage: Storage = app.state.storage\n note = Note(\n id=str(uuid.uuid4()),\n meeting_id=req.meeting_id,\n content=req.content,\n source_type=req.source_type,\n source_id=req.source_id,\n created_at=datetime.now(tz=timezone.utc),\n )\n storage.insert_note(note)\n return note\n\n\n@app.get(\"/meetings\", response_model=list[MeetingListItem])\nasync def list_meetings_endpoint(\n limit: int = Query(default=100, ge=1, le=500),\n) -> list[MeetingListItem]:\n \"\"\"List past meetings (newest first) for the history browser.\"\"\"\n storage: Storage = app.state.storage\n items: list[MeetingListItem] = []\n for m in storage.list_meetings(limit=limit):\n first_text = str(m.get(\"first_text\") or \"\").strip()\n title = first_text[:80] if first_text else None\n items.append(\n MeetingListItem(\n id=str(m[\"id\"]),\n started_at=m[\"started_at\"], # type: ignore[arg-type]\n ended_at=m[\"ended_at\"], # type: ignore[arg-type]\n preferred_summary_language=str(m[\"preferred_summary_language\"]),\n segment_count=int(m[\"segment_count\"] or 0),\n title=title,\n )\n )\n return items\n\n\n@app.delete(\"/meetings/{meeting_id}\")\nasync def delete_meeting_endpoint(meeting_id: str) -> dict[str, bool]:\n \"\"\"Delete a past meeting and all of its data (transcript, summaries, notes).\"\"\"\n storage: Storage = app.state.storage\n if storage.get_meeting(meeting_id) is None:\n raise HTTPException(status_code=404, detail=\"meeting not found\")\n storage.delete_meeting(meeting_id)\n return {\"deleted\": True}\n\n\n@app.get(\"/meetings/{meeting_id}/notes\", response_model=list[Note])\nasync def list_notes(meeting_id: str) -> list[Note]:\n storage: Storage = app.state.storage\n return storage.list_notes(meeting_id)\n\n\n@app.get(\"/meetings/{meeting_id}/transcript\", response_model=list[TranscriptSegment])\nasync def list_transcript(meeting_id: str) -> list[TranscriptSegment]:\n storage: Storage = app.state.storage\n return storage.list_segments(meeting_id)\n\n\n@app.get(\"/meetings/{meeting_id}/summaries\", response_model=list[SummarySnapshot])\nasync def list_summaries(\n meeting_id: str,\n summary_type: Optional[str] = Query(default=None),\n) -> list[SummarySnapshot]:\n storage: Storage = app.state.storage\n return storage.list_snapshots(meeting_id, summary_type=summary_type) # type: ignore[arg-type]\n\n\n@app.post(\"/meetings/{meeting_id}/export\", response_model=ExportMeetingResponse)\nasync def export_meeting_endpoint(\n meeting_id: str,\n bilingual: bool = Query(False),\n target_language: Optional[LanguageCode] = Query(None),\n req: ExportMeetingRequest | None = None,\n) -> ExportMeetingResponse:\n storage: Storage = app.state.storage\n req_data = req.model_dump(exclude_none=True) if req is not None else {}\n requested_export_root = req_data.pop(\"export_root\", None)\n requested_export_dir = req_data.pop(\"export_dir\", None)\n export_root = Path(requested_export_root or os.environ.get(\"MEETINGBRO_EXPORT_ROOT\", str(DEFAULT_EXPORT_ROOT)))\n try:\n return export_meeting(\n storage,\n meeting_id=meeting_id,\n export_root=export_root,\n export_dir=Path(requested_export_dir) if requested_export_dir else None,\n client_metadata=req_data,\n bilingual=bilingual,\n target_language=target_language,\n )\n except KeyError:\n raise HTTPException(status_code=404, detail=\"meeting not found\")\n except ValueError as exc:\n raise HTTPException(status_code=400, detail=str(exc))\n except OSError as exc:\n raise HTTPException(status_code=400, detail=f\"export failed: {exc}\")\n\n\ndef _normalize_mic_device(device: Optional[str]) -> \"int | str | None\":\n # sounddevice accepts an int index or a name string. The picker sends the\n # query_devices index as a string, so coerce numeric ids back to int.\n if device is None or device == \"\":\n return None\n return int(device) if str(device).isdigit() else device\n\n\ndef _build_audio_source(\n source: str,\n *,\n chunk_seconds: Optional[float] = None,\n mic_device: Optional[str] = None,\n speaker_name: Optional[str] = None,\n) -> AudioSource:\n \"\"\"Build an AudioSource from a URI-like spec.\n\n - \"mic\" or empty: microphone capture (offline / in-person mode).\n - \"loopback\" / \"system\": platform system-audio loopback (Windows WASAPI,\n macOS virtual loopback device, Linux PulseAudio/PipeWire monitor).\n - \"mixed\": microphone + system loopback mixed together.\n - \"sample\": replay the bundled demo clip (``data/sample_en.wav``) at natural\n pace so a first-time user / tester can see the full transcribe → summarize\n pipeline without configuring any audio device or meeting platform.\n - \"file:\": replay a WAV file for the E2E vertical-slice test.\n\n ``mic_device`` / ``speaker_name`` optionally pin a specific input / output\n device chosen in the UI; when omitted the platform default is used.\n \"\"\"\n chunk_seconds = chunk_seconds if chunk_seconds is not None else _env_float(\"MEETINGBRO_CHUNK_SECONDS\", 0.5)\n mic = _normalize_mic_device(mic_device)\n speaker = speaker_name or None\n if not source or source == \"mic\":\n return MicrophoneSource(sample_rate=16_000, chunk_seconds=chunk_seconds, device=mic)\n if source in (\"loopback\", \"system\"):\n return SystemAudioLoopbackSource(sample_rate=16_000, chunk_seconds=chunk_seconds, speaker_name=speaker)\n if source == \"mixed\":\n return MixedAudioSource(\n sample_rate=16_000,\n chunk_seconds=chunk_seconds,\n microphone_gain=_env_float(\"MEETINGBRO_MIXED_MIC_GAIN\", 1.2),\n system_gain=_env_float(\"MEETINGBRO_MIXED_SYSTEM_GAIN\", 1.0),\n auto_balance_enabled=_env_bool(\"MEETINGBRO_MIXED_AUTO_BALANCE_ENABLED\", True),\n max_microphone_boost=_env_float(\"MEETINGBRO_MIXED_MAX_MIC_BOOST\", 1.8),\n microphone_activity_floor=_env_float(\"MEETINGBRO_MIXED_MIC_ACTIVITY_FLOOR\", 0.008),\n balance_smoothing=_env_float(\"MEETINGBRO_MIXED_BALANCE_SMOOTHING\", 0.35),\n microphone_device=mic,\n speaker_name=speaker,\n )\n if source == \"sample\":\n sample_path = PROJECT_ROOT / \"data\" / \"sample_en.wav\"\n if not sample_path.exists():\n raise FileNotFoundError(sample_path)\n # realtime=True replays at natural pace: a live-feeling demo that also\n # respects the bounded input queue (offline-fast replay would flood it).\n return WavFileSource(\n sample_path,\n sample_rate=16_000,\n chunk_seconds=_env_float(\"MEETINGBRO_SAMPLE_CHUNK_SECONDS\", 0.5),\n realtime=True,\n )\n if source.startswith(\"file:\"):\n path = Path(source[len(\"file:\") :]).expanduser().resolve()\n if not path.exists():\n raise FileNotFoundError(path)\n return WavFileSource(\n path,\n sample_rate=16_000,\n chunk_seconds=_env_float(\"MEETINGBRO_FILE_CHUNK_SECONDS\", 5.0),\n realtime=False,\n )\n raise ValueError(f\"unsupported audio source: {source}\")\n\n\nasync def _forward_events(manager: SessionManager, ws: WebSocket) -> None:\n async for ev in manager.events():\n await ws.send_text(json.dumps({\"type\": ev.type, \"payload\": ev.payload}))\n\n\n@app.websocket(\"/ws/session\")\nasync def session_ws(\n ws: WebSocket,\n source: str = Query(default=\"mic\"),\n summary_language: str = Query(default=\"en\"),\n forced_language: Optional[str] = Query(default=None),\n vocabulary_hint: Optional[str] = Query(default=None),\n runtime_profile: Optional[str] = Query(default=None),\n audio_device: Optional[str] = Query(default=None),\n loopback_device: Optional[str] = Query(default=None),\n) -> None:\n await ws.accept()\n\n summary_lang: LanguageCode = summary_language if summary_language in (\"zh\", \"en\", \"de\") else \"en\" # type: ignore[assignment]\n hardware_profile = getattr(app.state, \"hardware_profile\", None)\n default_runtime_profile = (\n hardware_profile.recommended_runtime_profile\n if hardware_profile is not None\n else DEFAULT_RUNTIME_PROFILE\n )\n profile_name = normalize_runtime_profile(\n runtime_profile or os.environ.get(\"MEETINGBRO_RUNTIME_PROFILE\") or default_runtime_profile\n )\n profile_settings = _runtime_settings_from_profile(profile_name)\n profile_settings[\"language_lock_enabled\"] = _language_lock_enabled_for_speech(forced_language)\n chunk_seconds = _chunk_seconds_for_profile(profile_name)\n if _should_load_preview_asr(\n profile_settings=profile_settings,\n preview_backend_name=getattr(app.state, \"preview_asr_backend_name\", \"shared\"),\n ):\n await _ensure_preview_asr_loaded(app, profile_name=profile_name)\n session_preview_asr, session_preview_backend_name, session_preview_device = _resolve_preview_runtime(\n profile_settings=profile_settings,\n preview_asr=app.state.preview_asr,\n preview_backend_name=app.state.preview_asr_backend_name,\n preview_device=getattr(app.state, \"preview_asr_device\", None),\n formal_device=getattr(app.state, \"formal_asr_device\", \"cpu\"),\n )\n profile_name, performance_warning = _enforce_performance_requires_qwen(\n profile_name,\n preview_backend_name=session_preview_backend_name,\n )\n if performance_warning is not None:\n profile_settings = _runtime_settings_from_profile(profile_name)\n profile_settings[\"language_lock_enabled\"] = _language_lock_enabled_for_speech(forced_language)\n chunk_seconds = _chunk_seconds_for_profile(profile_name)\n session_preview_asr, session_preview_backend_name, session_preview_device = _resolve_preview_runtime(\n profile_settings=profile_settings,\n preview_asr=app.state.preview_asr,\n preview_backend_name=app.state.preview_asr_backend_name,\n preview_device=getattr(app.state, \"preview_asr_device\", None),\n formal_device=getattr(app.state, \"formal_asr_device\", \"cpu\"),\n )\n await ws.send_text(\n json.dumps(\n {\n \"type\": \"error\",\n \"payload\": ErrorPayload(\n code=\"performance_requires_qwen\",\n message=performance_warning,\n ).model_dump(),\n }\n )\n )\n\n try:\n audio_source = _build_audio_source(\n source,\n chunk_seconds=chunk_seconds,\n mic_device=audio_device,\n speaker_name=loopback_device,\n )\n except Exception as exc:\n await ws.send_text(\n json.dumps(\n {\n \"type\": \"error\",\n \"payload\": ErrorPayload(code=\"bad_source\", message=str(exc)).model_dump(),\n }\n )\n )\n await ws.close()\n return\n\n # A live preview lane needs a dedicated preview model (qwen3 / faster_whisper).\n # When none is available the backend resolves to \"shared\"; rather than run an\n # untested shared-model preview, disable preview for this session so behaviour\n # matches the stable formal-only path.\n if session_preview_backend_name == \"shared\":\n profile_settings[\"fast_preview_enabled\"] = False\n\n # Speaker diarization is shared across sessions; reset its accumulated\n # speaker state so labels start fresh for this meeting.\n session_diarizer = getattr(app.state, \"diarizer\", None)\n if session_diarizer is not None:\n try:\n session_diarizer.reset()\n except Exception as exc:\n logger.warning(\"diarizer reset failed: %s\", exc)\n\n config = SessionConfig(\n audio_source=audio_source,\n audio_source_name=source,\n audio_chunk_seconds=chunk_seconds,\n runtime_profile=profile_name,\n asr=app.state.asr,\n diarizer=session_diarizer,\n preview_asr=session_preview_asr,\n preview_asr_backend_name=session_preview_backend_name,\n hardware_profile_label=getattr(app.state.hardware_profile, \"label\", \"unknown\"),\n hardware_summary=getattr(app.state.hardware_profile, \"summary\", None),\n formal_asr_device=getattr(app.state, \"formal_asr_device\", \"cpu\"),\n preview_asr_device=session_preview_device,\n compute_gpu_available=getattr(app.state.hardware_profile, \"ctranslate2_cuda_available\", False),\n preview_asr_fallback_on_error=_env_bool(\n \"MEETINGBRO_PREVIEW_FALLBACK_ON_ERROR\",\n session_preview_backend_name != \"qwen3\",\n ),\n summarizer=LLMSummarizer(),\n translator=LLMTranslator(),\n storage=app.state.storage,\n forced_language=forced_language,\n summary_language=summary_lang,\n live_translation_language=None,\n asr_accumulation_seconds=float(profile_settings[\"asr_accumulation_seconds\"]),\n silence_rms_threshold=_env_float(\"MEETINGBRO_SILENCE_RMS_THRESHOLD\", 0.002),\n asr_overlap_seconds=_env_float(\"MEETINGBRO_ASR_OVERLAP_SECONDS\", 0.0),\n vocabulary_hint=vocabulary_hint or os.environ.get(\"MEETINGBRO_VOCABULARY_HINT\") or None,\n suspicious_segment_no_speech_prob=_env_float(\n \"MEETINGBRO_SUSPICIOUS_SEGMENT_NO_SPEECH_PROB\",\n 0.6,\n ),\n suspicious_segment_avg_logprob=_env_float(\n \"MEETINGBRO_SUSPICIOUS_SEGMENT_AVG_LOGPROB\",\n -0.9,\n ),\n suspicious_segment_compression_ratio=_env_float(\n \"MEETINGBRO_SUSPICIOUS_SEGMENT_COMPRESSION_RATIO\",\n 2.1,\n ),\n asr_retry_enabled=_env_bool(\"MEETINGBRO_ASR_RETRY_ENABLED\", True),\n asr_safeguard_enabled=_env_bool(\"MEETINGBRO_ASR_SAFEGUARD_ENABLED\", True),\n asr_safeguard_rtf_threshold=_env_float(\"MEETINGBRO_ASR_SAFEGUARD_RTF_THRESHOLD\", 0.9),\n asr_safeguard_cooldown_windows=_env_int(\"MEETINGBRO_ASR_SAFEGUARD_COOLDOWN_WINDOWS\", 5),\n asr_early_flush_enabled=bool(profile_settings[\"asr_early_flush_enabled\"]),\n asr_early_flush_min_seconds=float(profile_settings[\"asr_early_flush_min_seconds\"]),\n silence_commit_min_confidence=float(profile_settings[\"silence_commit_min_confidence\"]),\n silence_commit_min_duration_seconds=float(profile_settings[\"silence_commit_min_duration_seconds\"]),\n denoise_enabled=_env_bool(\"MEETINGBRO_DENOISE_ENABLED\", False),\n denoise_strength=_env_float(\"MEETINGBRO_DENOISE_STRENGTH\", 1.1),\n denoise_noise_update_rms_threshold=_env_float(\n \"MEETINGBRO_DENOISE_NOISE_UPDATE_RMS_THRESHOLD\",\n 0.02,\n ),\n audio_conditioning_enabled=_env_bool(\"MEETINGBRO_AUDIO_CONDITIONING_ENABLED\", True),\n audio_conditioning_target_rms=_env_float(\"MEETINGBRO_AUDIO_CONDITIONING_TARGET_RMS\", 0.035),\n audio_conditioning_min_rms=_env_float(\"MEETINGBRO_AUDIO_CONDITIONING_MIN_RMS\", 0.003),\n audio_conditioning_max_gain=_env_float(\"MEETINGBRO_AUDIO_CONDITIONING_MAX_GAIN\", 2.5),\n audio_conditioning_peak_limit=_env_float(\"MEETINGBRO_AUDIO_CONDITIONING_PEAK_LIMIT\", 0.98),\n pre_vad_enabled=bool(profile_settings[\"pre_vad_enabled\"]),\n pre_vad_conditioning_enabled=_env_bool(\"MEETINGBRO_PRE_VAD_CONDITIONING_ENABLED\", True),\n pre_vad_conditioning_target_rms=_env_float(\"MEETINGBRO_PRE_VAD_CONDITIONING_TARGET_RMS\", 0.03),\n pre_vad_conditioning_min_rms=_env_float(\"MEETINGBRO_PRE_VAD_CONDITIONING_MIN_RMS\", 0.001),\n pre_vad_conditioning_max_gain=_env_float(\"MEETINGBRO_PRE_VAD_CONDITIONING_MAX_GAIN\", 4.0),\n pre_vad_threshold=_env_float(\"MEETINGBRO_PRE_VAD_THRESHOLD\", 0.30),\n pre_vad_energy_rms_threshold=_env_float(\"MEETINGBRO_PRE_VAD_ENERGY_RMS_THRESHOLD\", 0.005),\n pre_vad_trailing_silence_seconds=float(profile_settings[\"pre_vad_trailing_silence_seconds\"]),\n pre_vad_adaptive_trailing_silence_enabled=bool(profile_settings[\"pre_vad_adaptive_trailing_silence_enabled\"]),\n pre_vad_adaptive_fast_trailing_silence_seconds=float(profile_settings[\"pre_vad_adaptive_fast_trailing_silence_seconds\"]),\n pre_vad_max_segment_seconds=_env_float(\"MEETINGBRO_PRE_VAD_MAX_SEGMENT_SECONDS\", 8.0),\n rolling_window_seconds=float(profile_settings[\"rolling_window_seconds\"]),\n refinement_interval_seconds=float(profile_settings[\"refinement_interval_seconds\"]),\n refinement_window_seconds=_env_float(\"MEETINGBRO_REFINEMENT_WINDOW_SECONDS\", 0.0),\n summary_tail_seconds=float(profile_settings[\"summary_tail_seconds\"]),\n min_segments_for_refinement=int(profile_settings[\"min_segments_for_refinement\"]),\n rolling_interval_seconds=float(profile_settings[\"rolling_interval_seconds\"]),\n memory_interval_seconds=float(profile_settings[\"memory_interval_seconds\"]),\n cumulative_interval_seconds=float(profile_settings[\"cumulative_interval_seconds\"]),\n min_segments_for_rolling=int(profile_settings[\"min_segments_for_rolling\"]),\n min_segments_for_memory=int(profile_settings[\"min_segments_for_memory\"]),\n min_segments_for_cumulative=int(profile_settings[\"min_segments_for_cumulative\"]),\n weak_speech_rescue_enabled=bool(profile_settings[\"weak_speech_rescue_enabled\"]),\n weak_speech_rescue_rms_min=_env_float(\"MEETINGBRO_WEAK_SPEECH_RESCUE_RMS_MIN\", 0.0008),\n weak_speech_rescue_rms_max=_env_float(\"MEETINGBRO_WEAK_SPEECH_RESCUE_RMS_MAX\", 0.02),\n weak_speech_rescue_fast_window_seconds=float(profile_settings[\"weak_speech_rescue_fast_window_seconds\"]),\n resource_governor_policy=str(profile_settings[\"resource_governor_policy\"]),\n resource_pressure_rtf_threshold=float(profile_settings[\"resource_pressure_rtf_threshold\"]),\n resource_critical_rtf_threshold=float(profile_settings[\"resource_critical_rtf_threshold\"]),\n resource_pressure_backlog_seconds=float(profile_settings[\"resource_pressure_backlog_seconds\"]),\n resource_critical_backlog_seconds=float(profile_settings[\"resource_critical_backlog_seconds\"]),\n weak_speech_rescue_window_seconds=_env_float(\"MEETINGBRO_WEAK_SPEECH_RESCUE_WINDOW_SECONDS\", 6.0),\n weak_speech_rescue_cooldown_seconds=_env_float(\"MEETINGBRO_WEAK_SPEECH_RESCUE_COOLDOWN_SECONDS\", 8.0),\n language_lock_enabled=bool(profile_settings[\"language_lock_enabled\"]),\n live_translation_backfill_limit=int(profile_settings[\"live_translation_backfill_limit\"]),\n live_translation_max_pending=int(profile_settings[\"live_translation_max_pending\"]),\n live_translation_safeguard_max_pending=int(profile_settings[\"live_translation_safeguard_max_pending\"]),\n audio_input_queue_max_seconds=_env_float(\"MEETINGBRO_AUDIO_INPUT_QUEUE_MAX_SECONDS\", 8.0),\n audio_input_queue_warning_seconds=_env_float(\"MEETINGBRO_AUDIO_INPUT_QUEUE_WARNING_SECONDS\", 3.0),\n fast_preview_enabled=bool(profile_settings[\"fast_preview_enabled\"]),\n fast_preview_interval_seconds=_env_float(\"MEETINGBRO_FAST_PREVIEW_INTERVAL_SECONDS\", 0.5),\n fast_preview_window_seconds=_env_float(\"MEETINGBRO_FAST_PREVIEW_WINDOW_SECONDS\", 3.0),\n fast_preview_max_backlog_seconds=_env_float(\"MEETINGBRO_FAST_PREVIEW_MAX_BACKLOG_SECONDS\", 0.5),\n fast_preview_max_asr_realtime_factor=_env_float(\"MEETINGBRO_FAST_PREVIEW_MAX_ASR_RTF\", 0.65),\n fast_preview_min_rms=_env_float(\"MEETINGBRO_FAST_PREVIEW_MIN_RMS\", 0.002),\n qwen_orphan_max_age_seconds=_env_float(\"MEETINGBRO_QWEN_ORPHAN_MAX_AGE_SECONDS\", 2.0),\n qwen_startup_draft_enabled=_env_bool(\"MEETINGBRO_QWEN_STARTUP_DRAFT_ENABLED\", True),\n qwen_startup_draft_window_seconds=_env_float(\"MEETINGBRO_QWEN_STARTUP_DRAFT_WINDOW_SECONDS\", 20.0),\n qwen_startup_draft_grace_seconds=_env_float(\"MEETINGBRO_QWEN_STARTUP_DRAFT_GRACE_SECONDS\", 1.2),\n qwen_targeted_retry_enabled=_env_bool(\"MEETINGBRO_QWEN_TARGETED_RETRY_ENABLED\", True),\n qwen_targeted_retry_margin_seconds=_env_float(\"MEETINGBRO_QWEN_TARGETED_RETRY_MARGIN_SECONDS\", 0.35),\n qwen_targeted_retry_max_audio_seconds=_env_float(\"MEETINGBRO_QWEN_TARGETED_RETRY_MAX_AUDIO_SECONDS\", 5.0),\n preview_stale_tolerance_seconds=_env_float(\"MEETINGBRO_PREVIEW_STALE_TOLERANCE_SECONDS\", 0.30),\n asr_executor_workers=_env_int(\n \"MEETINGBRO_ASR_EXECUTOR_WORKERS\",\n _recommended_asr_executor_workers(),\n ),\n preview_asr_executor_workers=_env_int(\n \"MEETINGBRO_PREVIEW_ASR_EXECUTOR_WORKERS\",\n _recommended_preview_asr_executor_workers(),\n ),\n summary_executor_workers=_env_int(\n \"MEETINGBRO_SUMMARY_EXECUTOR_WORKERS\",\n _recommended_summary_executor_workers(),\n ),\n translation_executor_workers=_env_int(\n \"MEETINGBRO_TRANSLATION_EXECUTOR_WORKERS\",\n _recommended_translation_executor_workers(),\n ),\n )\n manager = SessionManager(config)\n await manager.start()\n logger.info(\n \"session ws accepted meeting_id=%s source=%s profile=%s chunk=%.2fs accum=%.2fs\",\n manager.meeting_id,\n source,\n profile_name,\n chunk_seconds,\n config.asr_accumulation_seconds,\n )\n\n forward_task = asyncio.create_task(_forward_events(manager, ws))\n stop_requested = False\n\n try:\n while True:\n msg = await ws.receive_text()\n try:\n data = json.loads(msg)\n except json.JSONDecodeError:\n continue\n if data.get(\"type\") == \"save_note\":\n payload = data.get(\"payload\") or {}\n content = (payload.get(\"content\") or \"\").strip()\n if content or payload.get(\"source_type\") == \"bookmark\":\n note = manager.save_note(\n content=content,\n source_type=payload.get(\"source_type\"),\n source_id=payload.get(\"source_id\"),\n )\n await ws.send_text(\n json.dumps(\n {\"type\": \"note_saved\", \"payload\": note.model_dump(mode=\"json\")}\n )\n )\n elif data.get(\"type\") == \"update_settings\":\n payload = data.get(\"payload\") or {}\n next_summary_language = payload.get(\"summary_language\")\n summary_lang = (\n next_summary_language\n if next_summary_language in (\"zh\", \"en\", \"de\")\n else None\n )\n next_profile = normalize_runtime_profile(payload.get(\"runtime_profile\") or manager._cfg.runtime_profile)\n forced_language = payload.get(\"forced_language\")\n if forced_language == \"auto\":\n forced_language = None\n elif forced_language not in (None, \"zh\", \"en\", \"de\"):\n forced_language = manager._cfg.forced_language\n live_translation_language = payload.get(\"subtitle_language\")\n if next_profile == \"summary_only\":\n live_translation_language = None\n if live_translation_language == \"off\":\n live_translation_language = None\n elif live_translation_language not in (None, \"zh\", \"en\", \"de\"):\n live_translation_language = manager._cfg.live_translation_language\n if next_profile == \"summary_only\":\n live_translation_language = None\n next_profile_settings = _runtime_settings_from_profile(next_profile)\n next_profile_settings[\"language_lock_enabled\"] = _language_lock_enabled_for_speech(forced_language)\n next_chunk_seconds = _chunk_seconds_for_profile(next_profile)\n if _should_load_preview_asr(\n profile_settings=next_profile_settings,\n preview_backend_name=getattr(app.state, \"preview_asr_backend_name\", \"shared\"),\n ):\n await _ensure_preview_asr_loaded(app, profile_name=next_profile)\n next_preview_asr, next_preview_backend_name, next_preview_device = _resolve_preview_runtime(\n profile_settings=next_profile_settings,\n preview_asr=app.state.preview_asr,\n preview_backend_name=app.state.preview_asr_backend_name,\n preview_device=getattr(app.state, \"preview_asr_device\", None),\n formal_device=getattr(app.state, \"formal_asr_device\", \"cpu\"),\n )\n next_profile, performance_warning = _enforce_performance_requires_qwen(\n next_profile,\n preview_backend_name=next_preview_backend_name,\n )\n if performance_warning is not None:\n next_profile_settings = _runtime_settings_from_profile(next_profile)\n next_profile_settings[\"language_lock_enabled\"] = _language_lock_enabled_for_speech(forced_language)\n next_chunk_seconds = _chunk_seconds_for_profile(next_profile)\n next_preview_asr, next_preview_backend_name, next_preview_device = _resolve_preview_runtime(\n profile_settings=next_profile_settings,\n preview_asr=app.state.preview_asr,\n preview_backend_name=app.state.preview_asr_backend_name,\n preview_device=getattr(app.state, \"preview_asr_device\", None),\n formal_device=getattr(app.state, \"formal_asr_device\", \"cpu\"),\n )\n await ws.send_text(\n json.dumps(\n {\n \"type\": \"error\",\n \"payload\": ErrorPayload(\n code=\"performance_requires_qwen\",\n message=performance_warning,\n ).model_dump(),\n }\n )\n )\n # No dedicated preview model → disable preview rather than run a\n # shared-model preview (mirrors the session-start guard).\n if next_preview_backend_name == \"shared\":\n next_profile_settings[\"fast_preview_enabled\"] = False\n next_vocabulary_hint = payload.get(\"vocabulary_hint\", manager._cfg.vocabulary_hint)\n if next_vocabulary_hint is not None:\n next_vocabulary_hint = str(next_vocabulary_hint).strip() or None\n next_source = payload.get(\"source\")\n next_audio_device = payload.get(\"audio_device\", audio_device)\n next_loopback_device = payload.get(\"loopback_device\", loopback_device)\n profile_changed = next_profile != manager._cfg.runtime_profile\n chunk_changed = abs(next_chunk_seconds - manager._cfg.audio_chunk_seconds) > 1e-6\n device_changed = next_audio_device != audio_device or next_loopback_device != loopback_device\n effective_source = next_source or source\n if effective_source and (next_source != source or device_changed or (profile_changed and chunk_changed)):\n try:\n next_audio_source = _build_audio_source(\n effective_source,\n chunk_seconds=next_chunk_seconds,\n mic_device=next_audio_device,\n speaker_name=next_loopback_device,\n )\n except Exception as exc:\n await ws.send_text(\n json.dumps(\n {\n \"type\": \"error\",\n \"payload\": ErrorPayload(code=\"bad_source\", message=str(exc)).model_dump(),\n }\n )\n )\n else:\n manager.update_audio_source(\n next_audio_source,\n source_name=effective_source,\n chunk_seconds=next_chunk_seconds,\n )\n source = effective_source\n audio_device = next_audio_device\n loopback_device = next_loopback_device\n manager.update_preview_runtime(\n preview_asr=next_preview_asr,\n preview_asr_backend_name=next_preview_backend_name,\n preview_asr_device=next_preview_device,\n preview_asr_fallback_on_error=_env_bool(\n \"MEETINGBRO_PREVIEW_FALLBACK_ON_ERROR\",\n next_preview_backend_name != \"qwen3\",\n ),\n )\n manager.update_runtime_settings(\n forced_language=forced_language,\n summary_language=summary_lang,\n live_translation_language=live_translation_language,\n vocabulary_hint=next_vocabulary_hint,\n runtime_profile=next_profile,\n runtime_settings={\n **next_profile_settings,\n \"audio_chunk_seconds\": next_chunk_seconds,\n },\n )\n elif data.get(\"type\") == \"pause\":\n await manager.pause()\n elif data.get(\"type\") == \"resume\":\n await manager.resume()\n elif data.get(\"type\") == \"request_summary\":\n payload = data.get(\"payload\") or {}\n summary_type = payload.get(\"summary_type\")\n if summary_type in {\"rolling_summary\", \"cumulative_meeting_summary\"}:\n await manager.request_summary(summary_type)\n elif data.get(\"type\") == \"stop\":\n stop_requested = True\n break\n except WebSocketDisconnect:\n pass\n finally:\n await manager.stop(graceful=stop_requested)\n forward_task.cancel()\n try:\n await forward_task\n except (asyncio.CancelledError, Exception):\n pass\n try:\n await ws.close()\n except Exception:\n pass\n\n\ndef run() -> None:\n # Host/port are read from the environment so the Electron supervisor (or a\n # manual launcher) can pick a free port without code changes. Defaults match\n # the historical hardcoded values.\n uvicorn.run(\n \"meetingbro.main:app\",\n host=os.environ.get(\"MEETINGBRO_HOST\", \"127.0.0.1\"),\n port=_env_int(\"MEETINGBRO_PORT\", 8765),\n reload=False,\n log_level=\"info\",\n )\n\n\nif __name__ == \"__main__\":\n run()\n"}}, "docs/llm-providers.md": {"a7dd106ee474544a47a9637ca7f9449ac1086a13": {"t": 1785674509.7761025, "n": 7253, "h": "bfadc2633222a341", "c": "# LLM Provider Configuration\n\nMeetingBro uses any **OpenAI-compatible API** for meeting summaries and translation.\nThe three environment variables that control it are:\n\n```env\nMEETINGBRO_LLM_API_KEY=your_api_key_here\nMEETINGBRO_LLM_BASE_URL=https://api.openai.com/v1\nMEETINGBRO_LLM_MODEL=gpt-4o-mini\n```\n\n**If you leave these unset**, MeetingBro still works: transcription runs locally via Whisper,\nand summaries fall back to local heuristics (bullet-point extraction without LLM quality).\n\n---\n\n## Provider Options\n\n### 1. OpenAI\n\nThe reference provider. All other providers listed here are compatible with the same API format.\n\n| | |\n|---|---|\n| Sign up | https://platform.openai.com/signup |\n| API keys | https://platform.openai.com/api-keys |\n| Pricing | https://openai.com/api/pricing |\n| Data policy | https://openai.com/policies/api-data-usage-policies |\n\n**Recommended model:** `gpt-4o-mini` — good quality at low cost. Use `gpt-4o` for better summarization of long or complex meetings.\n\n```env\nMEETINGBRO_LLM_API_KEY=sk-...\nMEETINGBRO_LLM_BASE_URL=https://api.openai.com/v1\nMEETINGBRO_LLM_MODEL=gpt-4o-mini\n```\n\n**Notes:**\n- Pay-per-use pricing. New accounts may receive trial credits — check the dashboard.\n- Meeting summaries and translations involve relatively short text prompts; typical session cost is in the low cents range with `gpt-4o-mini`.\n- Audio and transcription never leave your machine; only the text transcript is sent to the LLM.\n\n---\n\n### 2. OpenRouter\n\nAn aggregator that routes to 200+ models from OpenAI, Anthropic, Meta, Mistral, and others through a single API. Useful if you want to switch models without changing infrastructure.\n\n| | |\n|---|---|\n| Sign up | https://openrouter.ai |\n| API keys | https://openrouter.ai/keys |\n| Pricing | https://openrouter.ai/models (per-model) |\n| Data policy | https://openrouter.ai/privacy |\n\n```env\nMEETINGBRO_LLM_API_KEY=sk-or-v1-...\nMEETINGBRO_LLM_BASE_URL=https://openrouter.ai/api/v1\nMEETINGBRO_LLM_MODEL=openai/gpt-4o-mini\n```\n\n**Model name format:** `provider/model-name`, e.g.:\n- `openai/gpt-4o-mini`\n- `anthropic/claude-3-haiku`\n- `meta-llama/llama-3.1-8b-instruct`\n- `mistralai/mistral-small`\n\n**Notes:**\n- Some models on OpenRouter are available with a free rate-limited tier — see https://openrouter.ai/models?q=free for the current list.\n- Pricing and availability change frequently; verify before choosing a model for production use.\n\n---\n\n### 3. Groq\n\nA cloud inference service focused on very fast response times (low-latency LLM inference via custom hardware). Good for meeting use cases where you want summaries to appear quickly.\n\n| | |\n|---|---|\n| Sign up | https://console.groq.com |\n| API keys | https://console.groq.com/keys |\n| Pricing | https://console.groq.com/settings/billing |\n| Supported models | https://console.groq.com/docs/models |\n\n```env\nMEETINGBRO_LLM_API_KEY=gsk_...\nMEETINGBRO_LLM_BASE_URL=https://api.groq.com/openai/v1\nMEETINGBRO_LLM_MODEL=llama-3.1-8b-instant\n```\n\n**Notes:**\n- Groq has historically offered a free tier with rate limits. Check current plan details at the link above.\n- Models available through Groq are open-weight models (Llama, Mixtral, Gemma); the quality for structured summaries is generally good.\n- Very fast generation speed compared to other providers.\n\n---\n\n### 4. Mistral AI\n\nMistral's own hosted inference for their models. Good for European users who prefer EU-based data processing.\n\n| | |\n|---|---|\n| Sign up | https://console.mistral.ai |\n| API keys | https://console.mistral.ai/api-keys |\n| Pricing | https://mistral.ai/technology/#pricing |\n| Data policy | https://mistral.ai/terms |\n\n```env\nMEETINGBRO_LLM_API_KEY=...\nMEETINGBRO_LLM_BASE_URL=https://api.mistral.ai/v1\nMEETINGBRO_LLM_MODEL=mistral-small-latest\n```\n\n**Notes:**\n- Models are hosted on EU servers, which may be relevant for GDPR compliance.\n- `mistral-small-latest` offers a good price-to-quality trade-off for summarization.\n- Check the console for any available free-tier or trial credits.\n\n---\n\n### 5. Ollama (fully local, no API key required)\n\nRun an LLM entirely on your own machine. No data leaves your device and no API key is needed. Performance depends on your hardware (CPU-only is slow for large models; GPU recommended).\n\n| | |\n|---|---|\n| Install | https://ollama.com/download |\n| Model library | https://ollama.com/library |\n\n**Setup:**\n```bash\n# Install Ollama, then pull a model\nollama pull llama3.2\n# Ollama starts a local server on port 11434\nollama serve\n```\n\n```env\nMEETINGBRO_LLM_API_KEY=ollama\nMEETINGBRO_LLM_BASE_URL=http://localhost:11434/v1\nMEETINGBRO_LLM_MODEL=llama3.2\n```\n\n**Recommended models by hardware:**\n\n| Hardware | Recommended model |\n|---|---|\n| 8 GB RAM (CPU) | `llama3.2:3b` or `qwen2.5:3b` |\n| 16 GB RAM (CPU) | `llama3.2` (8B) or `qwen2.5:7b` |\n| GPU with 8 GB VRAM | `llama3.1:8b` or `mistral` |\n| GPU with 16+ GB VRAM | `llama3.1:70b-q4` |\n\n**Notes:**\n- Summary and translation quality with small local models (3B–8B) is noticeably lower than GPT-4o-mini class models.\n- Suitable for privacy-sensitive environments or offline use.\n- The `MEETINGBRO_LLM_API_KEY` value is not checked by Ollama but the variable must be set to a non-empty string.\n\n---\n\n### 6. Together AI\n\nA cloud inference platform with a wide model selection and competitive pricing.\n\n| | |\n|---|---|\n| Sign up | https://api.together.xyz |\n| API keys | https://api.together.xyz/settings/api-keys |\n| Pricing | https://www.together.ai/pricing |\n\n```env\nMEETINGBRO_LLM_API_KEY=...\nMEETINGBRO_LLM_BASE_URL=https://api.together.xyz/v1\nMEETINGBRO_LLM_MODEL=meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo\n```\n\n**Notes:**\n- Check the website for any current trial credits for new accounts.\n- Good selection of open-weight models at low cost.\n\n---\n\n### 7. AWS Bedrock\n\nAmazon's managed LLM service. It does not expose a native OpenAI-compatible endpoint, but you can front it with a proxy.\n\n**Option A — LiteLLM proxy (recommended for teams):**\n```bash\npip install litellm\nlitellm --model bedrock/anthropic.claude-3-haiku-20240307-v1:0 --port 8080\n```\n```env\nMEETINGBRO_LLM_API_KEY=any_value\nMEETINGBRO_LLM_BASE_URL=http://localhost:8080/v1\nMEETINGBRO_LLM_MODEL=bedrock/anthropic.claude-3-haiku-20240307-v1:0\n```\n\n**Notes:**\n- Requires AWS credentials configured via `~/.aws/credentials` or environment variables.\n- Useful for enterprise deployments that already use AWS and need to keep data within a VPC.\n- LiteLLM documentation: https://docs.litellm.ai/docs/providers/bedrock\n\n---\n\n## Choosing a provider\n\n| Priority | Recommendation |\n|---|---|\n| Lowest cost | Groq free tier or OpenRouter free models (check current availability) |\n| Best quality | OpenAI `gpt-4o` or `gpt-4o-mini` |\n| Privacy / no data leaving machine | Ollama (local) |\n| EU data residency | Mistral AI |\n| Enterprise / existing AWS | AWS Bedrock via LiteLLM |\n| Flexible model switching | OpenRouter |\n\n---\n\n## Privacy reminder\n\nOnly the **text transcript** is sent to the LLM provider you configure. Audio capture and Whisper transcription run entirely on your machine. If you use a local LLM such as Ollama, no data leaves your device at all.\n\nTo verify this, see [app/backend/meetingbro/llm/openai_compatible.py](../app/backend/meetingbro/llm/openai_compatible.py).\n"}}}}