Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ jobs:
pip install -r requirements-pyinstaller.txt
shell: bash

# Windows-only audio/tray deps for the embedded meeting watcher. Without
# them package.py silently drops the watcher from the bundle and meeting
# auto-capture is dead in the shipped app.
- name: Install meeting-watcher dependencies (Windows)
if: runner.os == 'Windows'
run: pip install -r scripts/meeting_watcher/requirements.txt
shell: bash

- name: Build with PyInstaller
run: |
if [ "${{ matrix.variant }}" = "gpu" ]; then
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,4 @@ jobs:

- name: Run tests
run: |
pytest -q tests/test_diarization.py tests/test_audio_utils.py tests/test_transcription_model_cache.py tests/test_error_classifiers.py tests/test_transcription_flow.py tests/test_colab_proxy_flow.py tests/test_job_helpers_sync_retry.py tests/test_settings.py
pytest -q tests/test_diarization.py tests/test_audio_utils.py tests/test_transcription_model_cache.py tests/test_error_classifiers.py tests/test_transcription_flow.py tests/test_colab_proxy_flow.py tests/test_job_helpers_sync_retry.py tests/test_settings.py tests/test_meeting_watcher.py tests/test_watcher_status.py
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ __pycache__/
*.pyc
*.pyo
.venv/
.venv-win/
venv/
*.egg-info/

Expand All @@ -23,6 +24,9 @@ Thumbs.db
# Logs
*.log

# Meeting watcher output (runtime recordings — never commit audio)
meetings/

# PyInstaller / Build artifacts
build/
dist/
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ Keep a Changelog format.

## [Unreleased]

### ✨ Meeting auto-capture (Windows, beta)

- **Automatic meeting recording:** A background helper detects an in-progress call — Teams, Zoom, Webex, Google Meet in a browser, plus WhatsApp/Telegram/Signal/Slack/Discord voice calls — records both system audio (WASAPI loopback) and your microphone, and submits the result to the normal transcription queue when the call ends. Detection is fully local (pycaw audio-session inspection); no meeting APIs, no cloud.
- **Driven from the web UI:** New "Meeting auto-capture" section in the sidebar toggles recording on/off. The helper polls the toggle, so nothing records until you opt in.
- **Bundled with the Windows app:** The native build runs the watcher in-process — no separate install, no scheduled task. Docker and source installs can still install the standalone helper via a one-click `setup.bat`, offered by a first-run banner that disappears once the helper is alive.
- **Live recording indicator:** A red "Recording" chip with an elapsed timer and the detected app appears in the bottom-right stack while a meeting is being captured, and the finished transcript opens automatically once it's ready. A crashed helper can't leave the chip stuck on — the server expires a stale heartbeat.
- **Helper update prompt:** The watcher reports its version in its heartbeat; the UI offers a one-click in-place update when an installed helper is older than the one shipped with the running app.
- **Tray icon:** colour-coded status (grey off / green idle / red recording) with pause/resume auto-capture and "Open AmicoScript". Shown by the embedded watcher too, so the native app always has a visible recording indicator even with the browser tab closed.
- **Captures are written at 16 kHz mono**, the rate Whisper transcribes at — a 2 h meeting is ~230 MB instead of ~700 MB. Decimation goes through a windowed-sinc low-pass so nothing above 8 kHz aliases back into the speech band.

### 🐛 Fixes

- **Auto-captured meetings now use your actual transcription settings.** The watcher previously hard-coded `diarize=true` and `model=small` and never sent a language, so every recorded meeting was diarized regardless of the sidebar Speakers toggle. Model, language and diarization are now read from the app's saved settings, which the web UI keeps in sync; `AMICOSCRIPT_MODEL` / `AMICOSCRIPT_LANGUAGE` / `AMICOSCRIPT_DIARIZE` remain available to pin an option for auto-captures only.
- **No more Windows-only setup nag on macOS/Linux/Docker browsers:** the `setup.bat` onboarding banner and download link are hidden unless the browser is running on Windows.
- **Uninstalling the helper actually stops it:** `uninstall-windows.ps1` now kills the running watcher process instead of leaving it recording until the next logoff.
- **Orphaned capture scratch files** (`capture-*.raw`, hundreds of MB after a hard kill mid-meeting) are cleaned up on watcher start.
- **Long meeting uploads no longer time out** at 60 s.
- **The Windows release actually ships the watcher.** The release workflow never installed `scripts/meeting_watcher/requirements.txt`, so `package.py`'s dependency check silently dropped the embedded watcher from the bundle and meeting auto-capture was dead in the packaged app. The build now installs them, warns loudly if they're missing, and bundles the tray dependencies too.



## [1.12.2] - 2026-06-03
Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ AmicoScript keeps everything local.

- 🎧 Transcribe audio and video (MP3, WAV, M4A, OGG, FLAC, AAC, MP4, MOV, MKV)
- 🎙️ Record directly from your microphone (with pause support)
- 📞 Auto-record meetings (Windows, beta) — detects Teams/Zoom/Meet/WhatsApp calls and transcribes them automatically
- 🔗 Import directly from video URLs (YouTube, TikTok, Instagram, Facebook, X, Vimeo, Twitch)
- 📚 Batch process multiple files at once
- 🧠 Whisper models (tiny → large-v3)
Expand Down Expand Up @@ -181,6 +182,43 @@ Your files will now be seamlessly processed on the cloud GPU, but saved and mana

---

## 📞 Optional: Automatic Meeting Recording (Windows, beta)

AmicoScript can notice when you are in a call, record it, and hand the audio to
the normal transcription queue when the call ends — so a meeting turns into a
searchable transcript without you touching anything.

Detection is entirely local: it inspects which processes hold active Windows
audio sessions. No meeting APIs, no calendar access, nothing leaves your machine.
It recognises dedicated meeting apps (Teams, Zoom, Webex, GoToMeeting, Whereby,
RingCentral) whenever they play audio, and catches browser meetings such as
Google Meet plus chat-app calls (WhatsApp, Telegram, Signal, Slack, Discord) by
spotting any app using your microphone and speakers at the same time.

**Turning it on:** open the sidebar → **Meeting auto-capture** → *Auto-record
meetings*. Nothing is ever recorded until you flip that switch.

- **Windows app:** the helper is built in. Just use the toggle.
- **Docker / running from source:** the app cannot reach your host's audio, so a
small background helper is installed separately. A banner offers a one-click
`setup.bat`, or run `scripts\meeting_watcher\setup.bat` yourself. No admin
rights needed. See [scripts/meeting_watcher/README.md](scripts/meeting_watcher/README.md).

While a meeting is being captured you get a red **Recording** chip with a live
timer in the app, a coloured tray icon (right-click to pause), and desktop
notifications when recording starts and stops. Recordings shorter than 15
seconds are discarded as false triggers. Meetings are transcribed with the same
model, language and diarization settings shown in your sidebar.

> ⚠️ **Recording a conversation may require the consent of everyone involved and
> may be restricted by your employer's policy or local law. Make sure you are
> allowed to record before enabling this.**

macOS and Linux are not supported yet — the capture and detection layers are
Windows-specific.

---

## 🧩 Optional: Speaker Diarization

Uses `pyannote` and requires a Hugging Face token.
Expand Down
197 changes: 192 additions & 5 deletions backend/api/routes/settings.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,212 @@
"""Settings endpoints."""

from fastapi import APIRouter, Form
import asyncio
import platform
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path

from settings import _load_settings, _save_settings
from fastapi import APIRouter, Form, HTTPException

from settings import (
_get_meeting_capture_enabled,
_get_transcription_defaults,
_load_settings,
_save_settings,
_set_meeting_capture_enabled,
_set_transcription_defaults,
)

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 = 8.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 = 15.0

# Mirrors backend/main.py's BASE_DIR/SCRIPTS_DIR resolution (PyInstaller bundle
# vs running from source) so this route finds the same scripts/ the app served.
if hasattr(sys, "_MEIPASS"):
_BASE_DIR = Path(sys._MEIPASS)
else:
_BASE_DIR = Path(__file__).resolve().parents[2]
_SCRIPTS_DIR = _BASE_DIR / "scripts" if (_BASE_DIR / "scripts").exists() else _BASE_DIR.parent / "scripts"
_WATCHER_SRC_DIR = _SCRIPTS_DIR / "meeting_watcher"


def _bundled_watcher_version() -> str:
"""Version of the watcher.py shipped with the *running* app, parsed without
importing it (it pulls in Windows-only audio deps we don't want to load
just to read a constant)."""
try:
text = (_WATCHER_SRC_DIR / "watcher.py").read_text(encoding="utf-8")
m = re.search(r'WATCHER_VERSION\s*=\s*["\']([^"\']+)["\']', text)
return m.group(1) if m else ""
except Exception:
return ""


def _to_bool(value: str) -> bool:
return (value or "").strip().lower() in {"1", "true", "yes", "on"}


def _require_session_token(token: str) -> None:
import state
if not getattr(state, "exit_token", "") or token != state.exit_token:
raise HTTPException(403, "Invalid session token")


@router.get("/api/settings")
def get_settings() -> dict:
import state
settings = _load_settings()
defaults = _get_transcription_defaults()
return {
"hf_token": settings.get("hf_token", ""),
"exit_token": getattr(state, "exit_token", ""),
"meeting_capture_enabled": _get_meeting_capture_enabled(),
# Shared by the web UI and the meeting watcher so auto-captured
# meetings use the same model / language / diarize as manual uploads.
"default_model": defaults["default_model"],
"default_language": defaults["default_language"],
"default_diarize": defaults["default_diarize"],
}


@router.post("/api/settings")
async def save_settings(hf_token: str = Form("")) -> dict:
async def save_settings(
hf_token: str | None = Form(None),
model: str | None = Form(None),
language: str | None = Form(None),
diarize: str | None = Form(None),
) -> dict:
"""Persist HF token and/or transcription defaults.

Fields are optional so the UI can save the HF token alone (existing
behaviour) or push model/language/diarize without clearing the token.
"""
settings = _load_settings()
settings["hf_token"] = hf_token
_save_settings(settings)
if hf_token is not None:
settings["hf_token"] = hf_token
_save_settings(settings)
if model is not None or language is not None or diarize is not None:
_set_transcription_defaults(
model=model,
language=language,
diarize=_to_bool(diarize) if diarize is not None else None,
)
return {"ok": True, **_get_transcription_defaults()}


@router.post("/api/settings/meeting-capture")
async def set_meeting_capture(enabled: str = Form("false"), token: str = Form("")) -> dict:
"""Toggle the external meeting auto-capture watcher on/off.

The watcher (scripts/meeting_watcher/watcher.py) polls this flag and only
records meetings while it is enabled.
"""
_require_session_token(token)
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(""),
version: str = Form(""),
token: str = Form(""),
) -> dict:
"""Heartbeat from the meeting watcher (scripts/meeting_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.
"""
_require_session_token(token)
import state
is_recording = _to_bool(recording)
prev = getattr(state, "watcher_status", None) or {}
was_recording = bool(prev.get("recording")) and (time.time() - prev.get("ts", 0)) < WATCHER_STATUS_TTL
started_at = prev.get("started_at", 0.0) if (is_recording and was_recording) else (time.time() if is_recording else 0.0)
state.watcher_status = {
"recording": is_recording,
"app": (app or "").strip(),
"version": (version or "").strip(),
"ts": time.time(),
"started_at": started_at,
}
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
installed_version = st.get("version", "") if alive else ""
current_version = _bundled_watcher_version()
return {
"alive": alive,
"recording": fresh,
"app": st.get("app", "") if fresh else "",
"since": st.get("started_at", 0) if fresh else 0,
"installed_version": installed_version,
"current_version": current_version,
"update_available": bool(installed_version and current_version and installed_version != current_version),
}


def _install_watcher_sync() -> dict:
"""Copy the bundled watcher into %LOCALAPPDATA%\\AmicoScript\\watcher and
(re)register it, mirroring what setup.bat does by hand. Windows-only — the
app may itself run elsewhere (e.g. in Docker) while the browser/host it
needs to install onto is this machine, so callers must treat a platform
mismatch as "use the manual setup.bat download instead", not an error."""
if platform.system() != "Windows":
return {"ok": False, "error": "not_windows"}
if not _WATCHER_SRC_DIR.exists():
return {"ok": False, "error": "bundled watcher files not found"}

import os
dest = Path(os.environ.get("LOCALAPPDATA", "")) / "AmicoScript" / "watcher"
try:
shutil.copytree(
_WATCHER_SRC_DIR, dest, dirs_exist_ok=True,
ignore=shutil.ignore_patterns("meetings", "__pycache__", "*.pyc"),
)
except Exception as exc:
return {"ok": False, "error": f"copy failed: {exc}"}

installer = dest / "install-windows.ps1"
try:
proc = subprocess.run(
["powershell", "-ExecutionPolicy", "Bypass", "-NoProfile", "-File", str(installer)],
cwd=str(dest), capture_output=True, text=True, timeout=60,
)
except Exception as exc:
return {"ok": False, "error": f"install failed: {exc}"}
if proc.returncode != 0:
return {"ok": False, "error": (proc.stderr or proc.stdout or "install script failed").strip()[-500:]}
return {"ok": True}


@router.post("/api/watcher/install")
async def install_watcher(token: str = Form("")) -> dict:
"""Install/update the external watcher on this host, triggered from the
UI instead of the user manually running setup.bat. Only works when the
backend itself runs on the target Windows host (not e.g. in Docker) —
see _install_watcher_sync."""
_require_session_token(token)
return await asyncio.to_thread(_install_watcher_sync)
Loading
Loading