File size: 14,245 Bytes
75ba57a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5b9c6c1
69923c9
 
 
 
 
 
 
75ba57a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5b9c6c1
69923c9
 
 
 
 
 
 
75ba57a
 
5b9c6c1
69923c9
 
 
 
 
 
 
75ba57a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6c2c36b
75ba57a
 
6c2c36b
75ba57a
6c2c36b
75ba57a
 
 
 
6c2c36b
75ba57a
6c2c36b
75ba57a
 
6c2c36b
75ba57a
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
"""
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)