From 37a4acecd1717f437cb6f83873ec30cc7d4f0104 Mon Sep 17 00:00:00 2001 From: Simone Celestino Date: Sat, 20 Jun 2026 21:09:22 +0200 Subject: [PATCH 1/2] feat: record audio meetings --- .gitignore | 3 + backend/api/routes/settings.py | 68 +- backend/main.py | 76 ++ backend/settings.py | 13 + backend/state.py | 9 + frontend/index.html | 200 +++- package.py | 14 + scripts/teams_watcher/README.md | 188 ++++ scripts/teams_watcher/diag.py | 121 +++ scripts/teams_watcher/install-windows.ps1 | 75 ++ scripts/teams_watcher/logo.ico | Bin 0 -> 15739 bytes scripts/teams_watcher/requirements.txt | 8 + scripts/teams_watcher/setup.bat | 72 ++ scripts/teams_watcher/uninstall-windows.ps1 | 17 + scripts/teams_watcher/watcher.py | 974 ++++++++++++++++++++ tests/test_settings.py | 26 +- tests/test_watcher_status.py | 150 +++ 17 files changed, 2009 insertions(+), 5 deletions(-) create mode 100644 scripts/teams_watcher/README.md create mode 100644 scripts/teams_watcher/diag.py create mode 100644 scripts/teams_watcher/install-windows.ps1 create mode 100644 scripts/teams_watcher/logo.ico create mode 100644 scripts/teams_watcher/requirements.txt create mode 100644 scripts/teams_watcher/setup.bat create mode 100644 scripts/teams_watcher/uninstall-windows.ps1 create mode 100644 scripts/teams_watcher/watcher.py create mode 100644 tests/test_watcher_status.py diff --git a/.gitignore b/.gitignore index 4b13ed4..dec634e 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,9 @@ Thumbs.db # Logs *.log +# Meeting watcher output (runtime recordings — never commit audio) +meetings/ + # PyInstaller / Build artifacts build/ dist/ diff --git a/backend/api/routes/settings.py b/backend/api/routes/settings.py index fa08fd8..be2a369 100644 --- a/backend/api/routes/settings.py +++ b/backend/api/routes/settings.py @@ -1,11 +1,29 @@ """Settings endpoints.""" +import time + from fastapi import APIRouter, Form -from settings import _load_settings, _save_settings +from settings import ( + _get_meeting_capture_enabled, + _load_settings, + _save_settings, + _set_meeting_capture_enabled, +) router = APIRouter() +# A recording heartbeat older than this (seconds) is treated as idle, so a +# crashed/killed watcher never leaves the UI stuck showing "recording". +WATCHER_STATUS_TTL = 20.0 +# No heartbeat at all within this window means the watcher isn't running, so the +# UI shows its one-time setup prompt. Must exceed the watcher's heartbeat period. +WATCHER_ALIVE_TTL = 45.0 + + +def _to_bool(value: str) -> bool: + return (value or "").strip().lower() in {"1", "true", "yes", "on"} + @router.get("/api/settings") def get_settings() -> dict: @@ -14,6 +32,7 @@ def get_settings() -> dict: return { "hf_token": settings.get("hf_token", ""), "exit_token": getattr(state, "exit_token", ""), + "meeting_capture_enabled": _get_meeting_capture_enabled(), } @@ -23,3 +42,50 @@ async def save_settings(hf_token: str = Form("")) -> dict: settings["hf_token"] = hf_token _save_settings(settings) return {"ok": True} + + +@router.post("/api/settings/meeting-capture") +async def set_meeting_capture(enabled: str = Form("false")) -> dict: + """Toggle the external Teams auto-capture watcher on/off. + + The watcher (scripts/teams_watcher/watcher.py) polls this flag and only + records meetings while it is enabled. + """ + value = _to_bool(enabled) + _set_meeting_capture_enabled(value) + return {"ok": True, "enabled": value} + + +@router.post("/api/watcher/status") +async def set_watcher_status(recording: str = Form("false"), app: str = Form("")) -> dict: + """Heartbeat from the meeting watcher (scripts/teams_watcher/watcher.py). + + The watcher posts ``recording=true`` when a capture starts and again + periodically while it runs, then ``recording=false`` on stop. Stored only in + memory — see WATCHER_STATUS_TTL for the staleness rule. + """ + import state + state.watcher_status = { + "recording": _to_bool(recording), + "app": (app or "").strip(), + "ts": time.time(), + } + return {"ok": True} + + +@router.get("/api/watcher/status") +def get_watcher_status() -> dict: + """Current watcher state for the web UI: whether it's installed/running + (``alive``) and whether it's recording right now (``recording``).""" + import state + st = getattr(state, "watcher_status", None) or {} + ts = st.get("ts", 0) + age = time.time() - ts + alive = ts > 0 and age < WATCHER_ALIVE_TTL + fresh = bool(st.get("recording")) and age < WATCHER_STATUS_TTL + return { + "alive": alive, + "recording": fresh, + "app": st.get("app", "") if fresh else "", + "since": ts if fresh else 0, + } diff --git a/backend/main.py b/backend/main.py index e5ed6be..5246538 100644 --- a/backend/main.py +++ b/backend/main.py @@ -115,6 +115,82 @@ async def _startup() -> None: asyncio.create_task(_release_poller_loop()) except Exception: pass + _maybe_start_embedded_watcher() + + +# Handle to the embedded meeting watcher (thread + module), set by +# _maybe_start_embedded_watcher so the shutdown hook can signal a clean stop +# and let an in-progress capture finalize instead of being lost with the +# daemon thread. None when the watcher isn't running (Docker / non-Windows). +_watcher_thread = None +_watcher_module = None + + +def _watcher_output_dir() -> Path: + """User-writable folder for meeting captures (never Program Files).""" + try: + from config import STORAGE_ROOT + return Path(STORAGE_ROOT) / "meetings" + except Exception: + return Path.home() / "AmicoScript" / "meetings" + + +def _maybe_start_embedded_watcher() -> None: + """Run the meeting watcher in-process on the native Windows host. + + The watcher needs WASAPI/mic access, which only exists when AmicoScript runs + directly on the host (the PyInstaller build), not inside the Linux Docker + image. So we start it here only on Windows; Docker/other platforms fall back + to the external watcher (scripts/teams_watcher). Override with + AMICOSCRIPT_EMBEDDED_WATCHER=on|off|auto (default auto). + """ + import platform + + mode = os.environ.get("AMICOSCRIPT_EMBEDDED_WATCHER", "auto").lower() + if mode in {"0", "off", "false", "no"}: + return + if mode == "auto" and platform.system() != "Windows": + return + + watcher_dir = SCRIPTS_DIR / "teams_watcher" + if watcher_dir.exists() and str(watcher_dir) not in sys.path: + sys.path.insert(0, str(watcher_dir)) + os.environ.setdefault("AMICOSCRIPT_WATCHER_OUT", str(_watcher_output_dir())) + os.environ.setdefault("AMICOSCRIPT_URL", "http://127.0.0.1:8002") + + def _run() -> None: + global _watcher_module + try: + import watcher # noqa: imports pyaudiowpatch/pycaw — Windows-only + except Exception as exc: + # Audio deps not bundled, or not on a host that supports them. + print(f"Embedded meeting watcher unavailable ({exc}); " + "use the external watcher (scripts/teams_watcher) instead.") + return + _watcher_module = watcher + print("Embedded meeting watcher started (enable via the UI toggle).") + watcher.run_embedded(base_url="http://127.0.0.1:8002") + + import threading + global _watcher_thread + _watcher_thread = threading.Thread(target=_run, daemon=True, name="meeting-watcher") + _watcher_thread.start() + + +@app.on_event("shutdown") +async def _shutdown() -> None: + """Signal the embedded meeting watcher to stop so an in-progress capture + is finalized (WAV saved + transcription queued) before the process exits.""" + mod = _watcher_module + if mod is None or not hasattr(mod, "stop_embedded"): + return + try: + mod.stop_embedded() + except Exception: + return + # Give the watcher a moment to finalize a capture; don't block shutdown long. + if _watcher_thread is not None: + _watcher_thread.join(timeout=10) def _recover_interrupted_jobs() -> None: diff --git a/backend/settings.py b/backend/settings.py index 7282653..3742937 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -53,6 +53,19 @@ def _get_saved_hf_token() -> str: return settings.get("hf_token", "") or os.environ.get("HF_TOKEN", "") +def _get_meeting_capture_enabled() -> bool: + """Return whether the external Teams auto-capture watcher is enabled.""" + settings = _load_settings() + return bool(settings.get("meeting_capture_enabled", False)) + + +def _set_meeting_capture_enabled(enabled: bool) -> None: + """Persist the Teams auto-capture enabled flag.""" + settings = _load_settings() + settings["meeting_capture_enabled"] = bool(enabled) + _save_settings(settings) + + def _get_llm_settings() -> dict: """Return LLM config: base_url, model_name, api_key.""" settings = _load_settings() diff --git a/backend/state.py b/backend/state.py index 7ed6600..9b1d0d5 100644 --- a/backend/state.py +++ b/backend/state.py @@ -50,3 +50,12 @@ def _init_queue() -> None: # --------------------------------------------------------------------------- exit_token: str = "" + +# --------------------------------------------------------------------------- +# Meeting-watcher status — set by the external watcher (scripts/teams_watcher) +# via POST /api/watcher/status and read by the web UI to show a "recording" +# chip. Transient (in-memory only); a stale heartbeat is treated as idle by the +# route's TTL so a crashed watcher never leaves the UI stuck on "recording". +# --------------------------------------------------------------------------- + +watcher_status: dict = {"recording": False, "app": "", "ts": 0.0} diff --git a/frontend/index.html b/frontend/index.html index 9b25204..120ad6a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -854,6 +854,35 @@

AmicoScript

+ +
+ + Meeting auto-capture + + + + +
+
+ +
+ Auto-record meetings + Teams, Zoom, Meet, WhatsApp, Telegram & more — records & transcribes calls +
+
+

Checking helper…

+ +

+ Recording meetings may need all-party consent — check your policy. +

+
+
+
Tags - -