lecturelens-api / app /alert_engine.py
aliSaac510's picture
feat: adjust alert thresholds to match business logic for zoom recordings
5b9c6c1
Raw
History Blame Contribute Delete
14.2 kB
"""
LectureLens β€” Alert Engine
Reads thresholds.yaml once at startup and exposes generate_alerts().
"""
from __future__ import annotations
import math
from pathlib import Path
from typing import Any, Dict, List
import yaml
from app.schemas import Alert, AlertCategory, AlertSeverity, AudioMetrics, VideoMetrics
# ── Loader ────────────────────────────────────────────────────────────────────
_thresholds_cache: Dict[str, Any] | None = None
def load_thresholds(path: str = "thresholds.yaml") -> Dict[str, Any]:
global _thresholds_cache
if _thresholds_cache is None:
config_path = Path(path)
if not config_path.exists():
raise FileNotFoundError(f"Thresholds config not found at: {config_path}")
with config_path.open("r", encoding="utf-8") as f:
_thresholds_cache = yaml.safe_load(f)
return _thresholds_cache
def reload_thresholds(path: str = "thresholds.yaml") -> None:
"""Force a reload (useful for tests or live config updates)."""
global _thresholds_cache
_thresholds_cache = None
load_thresholds(path)
# ── Internal helpers ──────────────────────────────────────────────────────────
def _severity_from_ratio(ratio: float) -> AlertSeverity:
"""
ratio = how far beyond the threshold, relative to the threshold magnitude.
ratio > 0.5 β†’ critical
else β†’ warning
"""
if ratio > 0.5:
return AlertSeverity.critical
return AlertSeverity.warning
def _make_alert(
severity: AlertSeverity,
category: AlertCategory,
kpi: str,
message: str,
suggested_fix: str,
timestamp_range=None,
) -> Alert:
return Alert(
severity=severity,
category=category,
kpi=kpi,
message=message,
suggested_fix=suggested_fix,
timestamp_range=timestamp_range,
)
# ── Audio alerts ──────────────────────────────────────────────────────────────
def _audio_alerts(metrics: AudioMetrics, cfg: Dict[str, Any]) -> List[Alert]:
alerts: List[Alert] = []
ac = cfg.get("audio", {})
# integrated_loudness_lufs
if metrics.integrated_loudness_lufs is not None:
lufs_cfg = ac.get("integrated_loudness_lufs", {})
target = lufs_cfg.get("target", -14)
tolerance = lufs_cfg.get("tolerance", 2)
val = metrics.integrated_loudness_lufs
diff = abs(val - target)
if diff > tolerance:
ratio = (diff - tolerance) / max(abs(target), 1)
sev = _severity_from_ratio(ratio)
alerts.append(_make_alert(
sev, AlertCategory.audio, "integrated_loudness_lufs",
f"Integrated loudness is {val:.1f} LUFS β€” {diff - tolerance:.1f} LU "
f"{'below' if val < target else 'above'} the target of {target} LUFS.",
"Adjust microphone gain or apply loudness normalisation before uploading."
))
# true_peak_dbtp
if metrics.true_peak_dbtp is not None:
tp_max = ac.get("true_peak_dbtp", {}).get("max", -1.0)
val = metrics.true_peak_dbtp
if val > tp_max:
alerts.append(_make_alert(
AlertSeverity.critical, AlertCategory.audio, "true_peak_dbtp",
f"True peak is {val:.1f} dBTP β€” exceeds the {tp_max} dBTP ceiling.",
"Lower recording level or apply a true-peak limiter."
))
# clipped_samples_count
if metrics.clipped_samples_count > 0:
max_clips = ac.get("clipped_samples_count", {}).get("max", 0)
alerts.append(_make_alert(
AlertSeverity.critical, AlertCategory.audio, "clipped_samples_count",
f"{metrics.clipped_samples_count:,} clipped sample(s) detected β€” audio is distorted.",
"Reduce microphone input level to prevent clipping."
))
# snr_db
if metrics.snr_db is not None:
snr_min = ac.get("snr_db", {}).get("min", 20)
val = metrics.snr_db
if val < snr_min:
diff = snr_min - val
sev = AlertSeverity.critical if diff > 10 else AlertSeverity.warning
alerts.append(_make_alert(
sev, AlertCategory.audio, "snr_db",
f"SNR is {val:.1f} dB β€” below the {snr_min} dB minimum.",
"Use a directional microphone, reduce background noise, or move to a quieter room."
))
# loudness_range_lu
if metrics.loudness_range_lu is not None:
lra_cfg = ac.get("loudness_range_lu", {})
lra_min = lra_cfg.get("min", 4)
lra_max = lra_cfg.get("max", 15)
val = metrics.loudness_range_lu
if val < lra_min:
alerts.append(_make_alert(
AlertSeverity.info, AlertCategory.audio, "loudness_range_lu",
f"Loudness range is very low ({val:.1f} LU) β€” audio may sound over-compressed.",
"Avoid applying heavy dynamic compression to the recording."
))
elif val > lra_max:
alerts.append(_make_alert(
AlertSeverity.warning, AlertCategory.audio, "loudness_range_lu",
f"Loudness range is {val:.1f} LU β€” high variation indicates inconsistent mic distance.",
"Keep a consistent distance from the microphone throughout the lecture."
))
# silence_segments
silence_max = ac.get("silence_max_duration_sec", 20)
if metrics.longest_silence_seconds > silence_max:
count = sum(1 for s in metrics.silence_segments if (s.end - s.start) > silence_max)
alerts.append(_make_alert(
AlertSeverity.warning, AlertCategory.audio, "silence_segments",
f"Detected {count} long silence segment(s). Total silence: {metrics.total_silence_seconds:.1f}s, Longest: {metrics.longest_silence_seconds:.1f}s.",
"Check for accidental muting or long recording gaps.",
))
# dnsmos_ovrl
if metrics.dnsmos_ovrl is not None:
dnsmos_min = ac.get("dnsmos_ovrl", {}).get("min", 3.0)
val = metrics.dnsmos_ovrl
if val < dnsmos_min:
diff = dnsmos_min - val
sev = AlertSeverity.critical if diff > 1.0 else AlertSeverity.warning
alerts.append(_make_alert(
sev, AlertCategory.audio, "dnsmos_ovrl",
f"DNSMOS overall speech quality score is {val:.2f}/5 β€” below the {dnsmos_min} threshold.",
"Improve acoustic environment, use a better microphone, or apply noise suppression."
))
return alerts
# ── Video alerts ──────────────────────────────────────────────────────────────
def _video_alerts(metrics: VideoMetrics, cfg: Dict[str, Any]) -> List[Alert]:
alerts: List[Alert] = []
vc = cfg.get("video", {})
# brightness
if metrics.avg_brightness is not None:
br_cfg = vc.get("brightness", {})
br_min = br_cfg.get("min", 80)
br_max = br_cfg.get("max", 180)
val = metrics.avg_brightness
if val < br_min:
alerts.append(_make_alert(
AlertSeverity.warning, AlertCategory.video, "avg_brightness",
f"Average brightness is {val:.1f}/255 β€” video is too dark.",
"Increase room lighting or adjust camera exposure settings."
))
elif val > br_max:
alerts.append(_make_alert(
AlertSeverity.warning, AlertCategory.video, "avg_brightness",
f"Average brightness is {val:.1f}/255 β€” video is overexposed.",
"Reduce direct lighting on the speaker or lower camera exposure."
))
# sharpness
if metrics.avg_sharpness_laplacian is not None:
sharp_min = vc.get("sharpness_laplacian", {}).get("min", 100)
val = metrics.avg_sharpness_laplacian
if val < sharp_min:
sev = AlertSeverity.critical if val < sharp_min * 0.5 else AlertSeverity.warning
alerts.append(_make_alert(
sev, AlertCategory.video, "avg_sharpness_laplacian",
f"Image sharpness (Laplacian variance) is {val:.1f} β€” video appears blurry or out-of-focus.",
"Clean the camera lens, ensure correct focus, and avoid camera movement."
))
# dropped frames
if metrics.dropped_frames_ratio is not None:
df_max = vc.get("dropped_frames_ratio", {}).get("max", 0.01)
val = metrics.dropped_frames_ratio
if val > df_max:
alerts.append(_make_alert(
AlertSeverity.warning, AlertCategory.video, "dropped_frames_ratio",
f"Dropped frame ratio is {val * 100:.2f}% β€” exceeds the {df_max * 100:.1f}% limit.",
"Check recording hardware performance and storage write speed."
))
# frozen segments
freeze_max = vc.get("freeze_max_duration_sec", 5)
if metrics.longest_frozen_seconds > freeze_max:
count = sum(1 for s in metrics.frozen_segments if (s.end - s.start) > freeze_max)
alerts.append(_make_alert(
AlertSeverity.critical, AlertCategory.video, "frozen_segments",
f"Detected {count} frozen video segment(s). Total frozen: {metrics.total_frozen_seconds:.1f}s, Longest: {metrics.longest_frozen_seconds:.1f}s.",
"Check network stability and recording settings.",
))
# black segments
black_max = vc.get("black_max_duration_sec", 5)
if metrics.longest_black_seconds > black_max:
count = sum(1 for s in metrics.black_segments if (s.end - s.start) > black_max)
alerts.append(_make_alert(
AlertSeverity.warning, AlertCategory.video, "black_segments",
f"Detected {count} black screen segment(s). Total black: {metrics.total_black_seconds:.1f}s, Longest: {metrics.longest_black_seconds:.1f}s.",
"Check for accidental screen-sharing stops or camera disconnections.",
))
# compression artifacts
if metrics.compression_artifact_score is not None:
ca_max = vc.get("compression_artifact_score", {}).get("max", 0.3)
val = metrics.compression_artifact_score
if val > ca_max:
alerts.append(_make_alert(
AlertSeverity.info, AlertCategory.video, "compression_artifact_score",
f"Compression artefact score is {val:.2f} β€” noticeable blocking/banding.",
"Use a higher video bitrate in Zoom recording settings."
))
return alerts
# ── Public API ────────────────────────────────────────────────────────────────
def generate_alerts(
metrics: AudioMetrics | VideoMetrics,
media_type: str,
thresholds_path: str = "thresholds.yaml",
) -> List[Alert]:
"""
Compare metrics against thresholds and return a list of Alert objects.
"""
cfg = load_thresholds(thresholds_path)
if media_type == "audio":
return _audio_alerts(metrics, cfg) # type: ignore[arg-type]
return _video_alerts(metrics, cfg) # type: ignore[arg-type]
# ── Composite score helpers ───────────────────────────────────────────────────
def compute_audio_score(metrics: AudioMetrics) -> float:
"""
Weighted composite score (0–1) for audio quality.
Higher = better.
"""
scores = []
# DNSMOS: weight 0.35
if metrics.dnsmos_ovrl is not None:
scores.append((metrics.dnsmos_ovrl / 5.0, 0.35))
# SNR: weight 0.25
if metrics.snr_db is not None:
snr_score = min(metrics.snr_db / 40.0, 1.0) # 40 dB = perfect
scores.append((snr_score, 0.25))
# Loudness: weight 0.20 β€” penalty for distance from -14 LUFS
if metrics.integrated_loudness_lufs is not None:
dist = abs(metrics.integrated_loudness_lufs - (-14))
loudness_score = max(0.0, 1.0 - dist / 14.0)
scores.append((loudness_score, 0.20))
# Clipping penalty: weight 0.10
clip_score = 1.0 if metrics.clipped_samples_count == 0 else 0.0
scores.append((clip_score, 0.10))
# True peak: weight 0.10
if metrics.true_peak_dbtp is not None:
tp_score = 1.0 if metrics.true_peak_dbtp <= -1.0 else 0.0
scores.append((tp_score, 0.10))
if not scores:
return 0.5 # default when no data
total_weight = sum(w for _, w in scores)
weighted_sum = sum(s * w for s, w in scores)
return round(weighted_sum / total_weight, 3)
def compute_video_score(metrics: VideoMetrics) -> float:
"""
Weighted composite score (0–1) for video quality.
Higher = better.
"""
scores = []
# Sharpness: weight 0.45
if metrics.avg_sharpness_laplacian is not None:
sharp_score = min(metrics.avg_sharpness_laplacian / 300.0, 1.0)
scores.append((sharp_score, 0.45))
# Brightness: weight 0.25
if metrics.avg_brightness is not None:
br = metrics.avg_brightness
# Gaussian-like penalty centred on 130
br_score = max(0.0, 1.0 - abs(br - 130) / 80.0)
scores.append((br_score, 0.25))
# Dropped frames: weight 0.20
if metrics.dropped_frames_ratio is not None:
df_score = max(0.0, 1.0 - metrics.dropped_frames_ratio * 50)
scores.append((df_score, 0.20))
# Frozen segments penalty: weight 0.10
freeze_score = 1.0 if not metrics.frozen_segments else 0.3
scores.append((freeze_score, 0.10))
if not scores:
return 0.5
total_weight = sum(w for _, w in scores)
weighted_sum = sum(s * w for s, w in scores)
return round(weighted_sum / total_weight, 3)