Spaces:
Sleeping
Sleeping
| """ | |
| 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) | |