Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ node_modules/
*.log
.env
data/
.venv-test/
84 changes: 79 additions & 5 deletions backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
import stream_asr
import templates
from session import _to_traditional
from meeting import Meeting
from meeting import Meeting, valid_id, should_evict

try:
import torch
Expand All @@ -40,6 +40,18 @@
SR = 16000
DIARIZE_EVERY = float(os.environ.get("DIARIZE_EVERY", "6"))
MIN_AUDIO_S = 1.0
# A meeting is "live" (someone recording right now) if a track is active, has
# recorded audio, AND received an audio frame within this window. Recording
# appends frames every ~250ms (t.updated), so a paused/left track falls out of
# the window within seconds; a WS that merely connected in standby never had
# audio so it never counts as live.
LIVE_WINDOW_S = float(os.environ.get("MEETING_LIVE_WINDOW_S", "10"))
# Auto-cleanup TTL (was exposed in .env / start-meeting.sh but never wired up):
# a meeting idle (no audio) for longer than this AND not live is evicted from
# memory so it leaves the lobby and frees RAM. On-disk recordings are KEPT (a
# restart re-loads them); only the manual 清理/DELETE ever deletes from disk.
# Default 1 day; set 0 to disable. Eviction NEVER touches a live or connected mtg.
LOBBY_WINDOW_S = float(os.environ.get("LOBBY_WINDOW_S", "86400"))

app = FastAPI(title="voice-meeting backend (multi-track)")

Expand All @@ -50,15 +62,42 @@


def get_meeting(mid: str, title: str = "") -> Meeting:
if not valid_id(mid): # defense-in-depth (WS already validates)
raise ValueError(f"invalid meeting id: {mid!r}")
m = _meetings.get(mid)
if m is None:
m = Meeting(mid, ROOT, title)
_meetings[mid] = m
elif title and m.title == m.id:
m.title = title
m.save_meta()
return m


def _resume_meetings():
"""On startup, repopulate the lobby from MEETING_DATA so meetings survive a
backend restart. Resumed meetings can't be live (their WS clients are gone),
so their tracks are marked 'ended' — viewable (transcript persisted) but not
re-recorded. A meeting is only ever removed by the manual 清理 (DELETE)."""
if not ROOT.exists():
return
for d in sorted(ROOT.iterdir()):
if not d.is_dir() or d.name in _meetings:
continue
try:
m = Meeting(d.name, ROOT)
if not m.tracks: # skip empty/garbage dirs
continue
for t in m.tracks.values():
if t.status == "active":
t.status = "ended"
t._save_meta()
_meetings[d.name] = m
except Exception as e: # noqa: BLE001
log.warning("resume meeting %s failed: %s", d.name, e)
log.info("resumed %d meeting(s) from %s", len(_meetings), ROOT)


def _stream(mid: str, track: str) -> dict:
# buf = rolling tail (last WINDOW_S seconds) for live-caption decode;
# since = samples of new audio accumulated since the last caption decode.
Expand Down Expand Up @@ -109,9 +148,30 @@ async def diarization_worker():
log.warning("diarize %s/%s failed: %s", mid, tid, e)
if changed:
await broadcast(mid)
_evict_idle_meetings()
await asyncio.sleep(max(1.0, DIARIZE_EVERY - (loop.time() - t0)))


def _evict_idle_meetings():
"""Free memory for meetings idle past LOBBY_WINDOW_S. SAFETY (public demo):
a meeting is evicted ONLY if it is (a) not live, (b) has nobody connected
right now, and (c) idle longer than the TTL. Eviction is memory-only — the
on-disk recording is KEPT, so nothing viewable is destroyed (a restart
re-loads it); the manual 清理/DELETE remains the only path that deletes disk."""
wall = time.time() # t.updated is wall-clock, not loop time
for mid, m in list(_meetings.items()):
if _conns.get(mid): # someone is connected -> never evict
continue
idle = wall - m.last_activity()
if should_evict(idle, m.is_live(wall, LIVE_WINDOW_S), LOBBY_WINDOW_S):
_meetings.pop(mid, None)
for k in [k for k in _streams if k[0] == mid]:
_streams.pop(k, None)
for k in [k for k in _last_dur if k[0] == mid]:
_last_dur.pop(k, None)
log.info("evicted idle meeting %s from memory (disk kept; idle %.0fs)", mid, idle)


async def _warmup():
loop = asyncio.get_running_loop()
try:
Expand All @@ -125,6 +185,7 @@ async def _warmup():

@app.on_event("startup")
async def _startup():
_resume_meetings() # reload existing meetings so they persist across restarts
asyncio.create_task(_warmup())
asyncio.create_task(diarization_worker())
log.info("multi-track meeting backend up; data=%s cadence=%ss", ROOT, DIARIZE_EVERY)
Expand Down Expand Up @@ -160,18 +221,29 @@ async def summary_messages(payload: dict):
def meetings():
"""Active meeting list for the lobby."""
out = []
now = time.time()
for mid, m in _meetings.items():
ppl = [p for p in m.participants()]
# "live" = a track is actively RECORDING right now (active + has audio +
# an audio frame within LIVE_WINDOW_S). A standby join (active but no
# audio) or a paused/old track does NOT count. (shared with auto-cleanup)
live = m.is_live(now, LIVE_WINDOW_S)
# "ended" only if nothing is actively recording (has audio); a standby
# join must not flip a meeting back to 進行中.
ended = not any(t.status == "active" and t.has_audio() for t in m.tracks.values())
out.append({"id": mid, "title": m.title, "participants": len(ppl),
"names": [p["name"] for p in ppl], "created": int(m.created)})
out.sort(key=lambda x: -x["created"])
"names": [p["name"] for p in ppl], "created": int(m.created),
"started_at": int(m.started_at()), "ended": ended, "live": live})
out.sort(key=lambda x: -x["started_at"]) # newest first (reliable time)
return {"meetings": out}


@app.delete("/meetings/{mid}")
async def delete_meeting(mid: str):
"""Manual cleanup (lobby 清理 button): remove a meeting from the list AND
delete its on-disk recording. Irreversible."""
if not valid_id(mid): # reject path-traversal ids outright
return {"ok": False, "deleted": None}
for ws in list(_conns.get(mid, ())):
try:
await ws.close()
Expand All @@ -196,13 +268,15 @@ async def delete_meeting(mid: str):
async def ws_meeting(ws: WebSocket):
q = ws.query_params
mid = q.get("id")
if not mid:
track_id = q.get("track") # None => viewer
# H2: id/track become on-disk dir names -> whitelist them (block path
# traversal) BEFORE accepting. A viewer omits track (None) and is allowed.
if not valid_id(mid) or (track_id is not None and not valid_id(track_id)):
await ws.close(code=4000)
return
await ws.accept()
loop = asyncio.get_running_loop()
m = get_meeting(mid, q.get("title", ""))
track_id = q.get("track") # None => viewer
t = None
if track_id:
t = m.get_track(track_id, q.get("name", ""), q.get("mode", "room"))
Expand Down
87 changes: 85 additions & 2 deletions backend/meeting.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,108 @@
A meeting may mix both. merged() combines every track's segments onto one
meeting-wide timeline (aligned by each track's first-audio wall clock).
"""
import json
import re
import time
from pathlib import Path

from session import Session as Track

# Whitelist for meeting_id / track ids that become on-disk directory names.
# Both come straight from client query params, so they MUST be constrained to a
# safe charset (no ".", "/", "\" etc.) to prevent path traversal — the front-end
# genId() only ever produces [A-Za-z0-9_-]. Mirrors the resolve() guard already
# in the DELETE handler; this stops a bad id from creating a dir in the first place.
_ID_RE = re.compile(r"[A-Za-z0-9_-]{1,64}")


def valid_id(s) -> bool:
"""True iff s is a safe id (1-64 chars of [A-Za-z0-9_-]). Never raises."""
return isinstance(s, str) and _ID_RE.fullmatch(s) is not None


def should_evict(idle_s: float, is_live: bool, ttl_s: float) -> bool:
"""Auto-cleanup decision for a meeting (memory eviction only; disk is kept).

SAFETY: a live meeting is NEVER evicted; ttl_s <= 0 disables cleanup. Only a
meeting idle (no audio) for LONGER than the TTL and not currently live is
eligible — recent, still-viewable ended meetings stay until they age out."""
if ttl_s <= 0 or is_live:
return False
return idle_s > ttl_s


class Meeting:
def __init__(self, meeting_id: str, root: Path, title: str = "", sr: int = 16000):
self.id = meeting_id
self.sr = sr
self.root = root / meeting_id
self.root.mkdir(parents=True, exist_ok=True)
self.meta_path = self.root / "meeting.json" # meeting-level title/created
self.title = title or meeting_id
self.created = time.time()
# restore persisted meeting meta (title/created) so the lobby keeps the
# real name + order after a backend restart, not just the random id.
if self.meta_path.exists():
try:
mm = json.loads(self.meta_path.read_text())
self.title = title or mm.get("title", self.title)
self.created = mm.get("created", self.created)
except Exception: # noqa: BLE001
pass
self.tracks: dict[str, Track] = {}
# resume: load any existing tracks on disk
# resume: load any existing tracks on disk (dirs only; meeting.json is a file)
if self.root.exists():
for d in sorted(self.root.iterdir()):
if d.is_dir():
self.tracks[d.name] = Track(d.name, self.root, sr)
self.save_meta()

def save_meta(self):
try:
self.meta_path.write_text(json.dumps(
{"title": self.title, "created": self.created}, ensure_ascii=False))
except Exception: # noqa: BLE001
pass

def started_at(self) -> float:
"""Reliable meeting time for the lobby (date+time).

For a genuinely new meeting, meeting.json `created` (the lobby-join time)
is correct. For a LEGACY meeting whose `created` was overwritten by the
persistence restart, fall back to the earliest audio.pcm write time (the
actual recording). min() picks the right one in both cases: a new
meeting's created precedes its first audio; a legacy meeting's audio
predates its overwritten created. No-audio (standby) meetings keep created.
"""
earliest = None
for t in self.tracks.values():
try:
if t.audio_path.exists():
mt = t.audio_path.stat().st_mtime
if earliest is None or mt < earliest:
earliest = mt
except Exception: # noqa: BLE001
pass
return self.created if earliest is None else min(self.created, earliest)

def is_live(self, now: float, window: float) -> bool:
"""True iff a track is actively RECORDING right now: active + has real
audio + an audio frame within `window` seconds. A standby join (no audio)
or a paused/old/ended track does not count. Single source of truth for
the lobby's 進行中 badge AND the auto-cleanup safety check."""
return any(t.status == "active" and t.has_audio()
and (now - t.updated) <= window for t in self.tracks.values())

def last_activity(self) -> float:
"""Most recent track update (last audio frame / transcript). Falls back
to the meeting's created time when there are no tracks yet."""
ups = [t.updated for t in self.tracks.values()]
return max(ups) if ups else self.created

def get_track(self, track_id: str, name: str = "", mode: str = "room") -> Track:
if not valid_id(track_id):
raise ValueError(f"invalid track id: {track_id!r}")
t = self.tracks.get(track_id)
if t is None:
t = Track(track_id, self.root, self.sr, name=name, mode=mode)
Expand All @@ -38,11 +118,14 @@ def get_track(self, track_id: str, name: str = "", mode: str = "room") -> Track:
return t

def participants(self) -> list[dict]:
# Only tracks that actually recorded audio count as participants/speakers.
# Standby joins (connected, never pressed 開始 -> no audio.pcm) and any
# previously-accumulated empty tracks are excluded from the list & counts.
return [{
"track": tid,
"name": t.name or ("房間麥克風" if t.mode == "room" else "講者"),
"mode": t.mode,
} for tid, t in self.tracks.items()]
} for tid, t in self.tracks.items() if t.has_audio()]

def merged(self) -> dict:
t0s = [t.t0 for t in self.tracks.values() if t.t0]
Expand Down
51 changes: 49 additions & 2 deletions backend/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,22 @@ def _smooth_segments(segs: list[dict]) -> list[dict]:
return out


# ---- H1 防爆: per-track recording cap ---------------------------------------
# A single runaway / abandoned tab (or a malicious client) could otherwise stream
# 16k PCM forever and fill the disk. Cap the per-track audio length; once reached,
# further frames are dropped (append_pcm is a no-op). Generous default (6h) so it
# never bites a real meeting; set MEETING_MAX_TRACK_S=0 to disable the cap.
MAX_TRACK_S = float(os.environ.get("MEETING_MAX_TRACK_S", "21600")) # 6 hours


def over_capacity(num_bytes: int, sr: int, max_seconds: float) -> bool:
"""True iff `num_bytes` of 16-bit mono PCM @ sr already meets/exceeds the cap.
max_seconds <= 0 disables the cap (always False)."""
if max_seconds <= 0:
return False
return (num_bytes / 2 / sr) >= max_seconds


def _to_traditional(text: str) -> str:
if _s2twp and text:
try:
Expand Down Expand Up @@ -116,7 +132,10 @@ def __init__(self, track_id: str, root: Path, sr: int = 16000,
self.id = track_id
self.sr = sr
self.dir = root / track_id
self.dir.mkdir(parents=True, exist_ok=True)
# NOTE: the dir is created lazily on the FIRST real audio (append_pcm),
# not here. Merely joining/connecting a track (standby) must leave no
# on-disk footprint, so silent joins don't accumulate empty tracks that
# inflate the speaker/participant count.
self.audio_path = self.dir / "audio.pcm" # raw int16 LE mono @ sr
self.meta_path = self.dir / "meta.json"
self.lock = threading.Lock()
Expand Down Expand Up @@ -150,22 +169,42 @@ def _load_meta(self):
self.names = {int(k): v for k, v in m.get("names", {}).items()}
self.status = m.get("status", "active")
self.created = m.get("created", self.created)
self.updated = m.get("updated", self.created) # real last-audio time (for live detection after restart)
self._next_pid = m.get("next_pid", 0)
self.preset_spk_num = m.get("preset_spk_num", self.preset_spk_num)
self.device = m.get("device", "cuda")
self.name = m.get("name", self.name)
self.mode = m.get("mode", self.mode)
self.t0 = m.get("t0", None)
# persisted transcript -> viewable after a backend restart with
# no re-diarization needed (raw stitched segments).
self.segments = m.get("segments", [])
except Exception: # noqa: BLE001
log.warning("meta load failed for %s", self.id)

def has_audio(self) -> bool:
"""True only once this track has recorded real audio. A standby join
(connected but never pressed 開始) has no audio.pcm, so it is NOT a real
speaker and must be excluded from participant lists and counts."""
try:
return self.audio_path.exists() and self.audio_path.stat().st_size > 0
except Exception: # noqa: BLE001
return False

def _save_meta(self):
# A track is persisted only once it has real audio. Standby joins
# (set_device/set_speakers/rename before any recording) update in-memory
# state only — no dir/meta is written, so they neither hit disk nor
# accumulate. append_pcm persists on the first audio frame.
if not self.audio_path.exists():
return
self.meta_path.write_text(json.dumps({
"names": {str(k): v for k, v in self.names.items()},
"status": self.status, "created": self.created,
"updated": self.updated, "next_pid": self._next_pid,
"preset_spk_num": self.preset_spk_num, "device": self.device,
"name": self.name, "mode": self.mode, "t0": self.t0,
"segments": self.segments,
}, ensure_ascii=False))

def set_speakers(self, n):
Expand Down Expand Up @@ -195,11 +234,19 @@ def set_device(self, d):
# ---- audio ----
def append_pcm(self, pcm_bytes: bytes):
with self.lock:
if self.t0 is None:
# 防爆: drop frames once this track hits the recording cap.
cur = self.audio_path.stat().st_size if self.audio_path.exists() else 0
if over_capacity(cur, self.sr, MAX_TRACK_S):
return
first = self.t0 is None
if first:
self.t0 = time.time() # mark track start for timeline align
self.dir.mkdir(parents=True, exist_ok=True) # lazily materialise the track on first real audio
with open(self.audio_path, "ab") as f:
f.write(pcm_bytes)
self.updated = time.time()
if first:
self._save_meta() # now a real track: persist name/mode/t0/status

def get_audio(self) -> np.ndarray:
if not self.audio_path.exists():
Expand Down
Loading