Spaces:
Sleeping
Sleeping
| """ | |
| bark_classifier.py | |
| ------------------ | |
| Rule-based dog bark mood classifier for PawTalk. | |
| Design goals | |
| ------------ | |
| - Deterministic: same features always produce the same result. | |
| - Readable: every mood's logic is in its own clearly-labelled block. | |
| - Tweakable: all thresholds are named constants at the top of the file. | |
| - Observable: the returned dict explains *why* a mood was chosen. | |
| How it works | |
| ------------ | |
| 1. Raw librosa feature values are normalised to [0, 1] so thresholds | |
| are dimensionless and easy to reason about. | |
| 2. Each mood is scored by a small weighted formula (0.0 – 1.0). | |
| 3. A secondary *veto* check can hard-block a mood when a required | |
| condition is clearly absent (e.g. "warning" requires high energy). | |
| 4. The winning mood is the highest-scoring non-vetoed mood. | |
| 5. Confidence (0–100) is computed from the margin between the winner | |
| and the runner-up, scaled by how strongly the winner scored. | |
| 6. An explanation string names the top-3 features that pushed the | |
| decision and their qualitative values. | |
| Mood definitions | |
| ---------------- | |
| excited – high energy, dense rapid barks, bright tone | |
| playful – moderate energy, rhythmic, moderate pitch | |
| alert – sudden energy bursts, sharp rises, high ZCR | |
| anxious – irregular energy, chaotic rhythm, frequent pauses | |
| warning – sustained loud energy, low-pitched, minimal pauses | |
| """ | |
| import numpy as np | |
| # ============================================================================= | |
| # Tuneable thresholds | |
| # Change these to adjust classifier sensitivity — do not change the formulas. | |
| # ============================================================================= | |
| # --- Normalisation ranges (raw feature → 0..1) --- | |
| RMS_LO, RMS_HI = 0.010, 0.30 # overall loudness | |
| RMS_STD_LO, RMS_STD_HI = 0.000, 0.12 # loudness burstiness | |
| ZCR_LO, ZCR_HI = 0.020, 0.35 # spectral noisiness | |
| ZCR_STD_LO, ZCR_STD_HI = 0.000, 0.08 # ZCR variability | |
| CENTROID_LO, CENTROID_HI = 400.0, 4500.0 # spectral brightness (Hz) | |
| ROLLOFF_LO, ROLLOFF_HI = 800.0, 8000.0 # rolloff frequency (Hz) | |
| TEMPO_LO, TEMPO_HI = 50.0, 220.0 # BPM | |
| BEAT_REG_LO, BEAT_REG_HI = 0.00, 0.55 # IBI std dev; inf = fully irregular | |
| F0_LO, F0_HI = 60.0, 2500.0 # fundamental frequency (Hz) | |
| BURST_LO, BURST_HI = 0, 15 # burst count per clip | |
| PAUSE_LO, PAUSE_HI = 0, 10 # pause count per clip | |
| # --- Veto thresholds (normalised, 0–1 scale) --- | |
| # A mood is vetoed when a *required* feature falls outside its expected range. | |
| VETO_EXCITED_MIN_RMS = 0.40 # excited needs real loudness | |
| VETO_WARNING_MIN_RMS = 0.50 # warning bark must be substantial | |
| VETO_WARNING_MAX_F0 = 0.55 # warning bark should be low-pitched | |
| VETO_PLAYFUL_MAX_RMS = 0.85 # playful shouldn't be a full-on roar | |
| VETO_ANXIOUS_MIN_VAR = 0.20 # anxious needs some energy variation | |
| # --- Confidence calibration --- | |
| # confidence = base_score_weight * winner_score + margin_weight * margin | |
| CONF_SCORE_WEIGHT = 55 # how much the winner's raw score contributes (0–55) | |
| CONF_MARGIN_WEIGHT = 45 # how much the gap over runner-up contributes (0–45) | |
| # ============================================================================= | |
| # Public API | |
| # ============================================================================= | |
| MOODS = ("excited", "playful", "alert", "anxious", "warning") | |
| def classify_bark_mood(features: dict) -> dict: | |
| """ | |
| Classify the mood of a dog bark. | |
| Parameters | |
| ---------- | |
| features : dict | |
| Output of audio_features.extract_features(). | |
| Returns | |
| ------- | |
| dict with keys: | |
| mood : str — one of MOODS, or 'unknown' | |
| confidence : int — 0–100 | |
| explanation : str — plain-English reason for the prediction | |
| scores : dict — raw 0.0–1.0 score per mood (for debugging / charts) | |
| feature_summary : dict — human-readable normalised feature values | |
| """ | |
| nf = _normalise(features) | |
| scores = _score_all(nf) | |
| mood, confidence, explanation = _pick_winner(nf, scores, features) | |
| feature_summary = { | |
| "Energy": _qual(nf["rms"], ("quiet", "moderate", "loud")), | |
| "Energy variation": _qual(nf["rms_std"], ("steady", "variable", "spiky")), | |
| "Tone noisiness": _qual(nf["zcr"], ("tonal", "mixed", "noisy")), | |
| "Rhythm chaos": _qual(nf["beat_reg"], ("regular", "loose", "chaotic")), | |
| "Brightness": _qual(nf["centroid"], ("dark", "mid", "bright")), | |
| "Burst count": str(features.get("burst_count", 0)), | |
| "Pause count": str(features.get("pause_count", 0)), | |
| } | |
| return { | |
| "mood": mood, | |
| "confidence": confidence, | |
| "explanation": explanation, | |
| "scores": scores, | |
| "feature_summary": feature_summary, | |
| } | |
| # ============================================================================= | |
| # Normalisation | |
| # ============================================================================= | |
| def _normalise(features: dict) -> dict: | |
| """ | |
| Map raw feature values to [0, 1] using the reference ranges above. | |
| beat_regularity = inf (no beats found) maps to 1.0 (maximally irregular). | |
| """ | |
| def n(val, lo, hi): | |
| return float(np.clip((val - lo) / (hi - lo + 1e-9), 0.0, 1.0)) | |
| beat_raw = features.get("beat_regularity", float("inf")) | |
| beat_norm = 1.0 if not np.isfinite(beat_raw) else n(beat_raw, BEAT_REG_LO, BEAT_REG_HI) | |
| return { | |
| "rms": n(features.get("rms_mean", 0.0), RMS_LO, RMS_HI), | |
| "rms_std": n(features.get("rms_std", 0.0), RMS_STD_LO, RMS_STD_HI), | |
| "zcr": n(features.get("zcr_mean", 0.0), ZCR_LO, ZCR_HI), | |
| "zcr_std": n(features.get("zcr_std", 0.0), ZCR_STD_LO, ZCR_STD_HI), | |
| "centroid": n(features.get("spectral_centroid_mean", 0.0), CENTROID_LO, CENTROID_HI), | |
| "rolloff": n(features.get("spectral_rolloff_mean", 0.0), ROLLOFF_LO, ROLLOFF_HI), | |
| "tempo": n(features.get("tempo", 0.0), TEMPO_LO, TEMPO_HI), | |
| "beat_reg": beat_norm, | |
| "f0": n(features.get("f0_mean", 0.0), F0_LO, F0_HI), | |
| "burst": n(features.get("burst_count", 0), BURST_LO, BURST_HI), | |
| "pause": n(features.get("pause_count", 0), PAUSE_LO, PAUSE_HI), | |
| } | |
| # ============================================================================= | |
| # Scoring | |
| # Each mood formula is a weighted sum of soft-step activations. | |
| # _ramp(v, t) → 0..1 as v rises above threshold t (needs high value) | |
| # _ramp(v, t, flip=True) → 0..1 as v falls below t (needs low value) | |
| # The weights in each formula must sum to 1.0 for scores to stay in [0, 1]. | |
| # ============================================================================= | |
| def _ramp(value: float, threshold: float, flip: bool = False, span: float = 0.25) -> float: | |
| """ | |
| Soft linear ramp from 0 to 1. | |
| flip=False (default): score rises as value climbs above threshold. | |
| flip=True : score rises as value falls below threshold. | |
| span : width of the transition zone (smaller = sharper). | |
| """ | |
| if flip: | |
| return float(np.clip((threshold - value) / span, 0.0, 1.0)) | |
| return float(np.clip((value - threshold) / span, 0.0, 1.0)) | |
| def _score_all(nf: dict) -> dict: | |
| """Return a 0.0–1.0 score for every mood.""" | |
| return {mood: round(_SCORERS[mood](nf), 4) for mood in MOODS} | |
| def _score_excited(nf: dict) -> float: | |
| """ | |
| Excited bark: very loud, dense rapid bursts, bright and noisy tone. | |
| Typical: a dog at the door when owner arrives, or before a walk. | |
| Key features: high rms, many bursts, high zcr, bright centroid, fast tempo. | |
| """ | |
| return ( | |
| 0.30 * _ramp(nf["rms"], 0.60) # must be loud | |
| + 0.25 * _ramp(nf["burst"], 0.50) # frequent short bursts | |
| + 0.20 * _ramp(nf["zcr"], 0.55) # noisy / harmonic-rich | |
| + 0.15 * _ramp(nf["centroid"], 0.50) # bright, high-frequency content | |
| + 0.10 * _ramp(nf["tempo"], 0.45) # fast pace bonus | |
| ) | |
| def _score_playful(nf: dict) -> float: | |
| """ | |
| Playful bark: moderate energy with a bouncy, regular rhythm. | |
| Typical: invite-to-play bark, fetch excitement, tug-of-war. | |
| Key features: moderate rms, regular beat pattern, moderate zcr, some bursts. | |
| """ | |
| regularity = _ramp(nf["beat_reg"], 0.45, flip=True) # low beat_reg = regular | |
| return ( | |
| 0.30 * regularity # rhythmic is most important | |
| + 0.25 * _ramp(nf["rms"], 0.30) # needs some energy | |
| + 0.20 * _ramp(nf["burst"], 0.25) # a few bursts | |
| + 0.15 * _ramp(nf["zcr"], 0.35) # moderate noisiness | |
| + 0.10 * _ramp(nf["tempo"], 0.30) # moderate tempo | |
| ) | |
| def _score_alert(nf: dict) -> float: | |
| """ | |
| Alert bark: sharp sudden bursts with quick silences between them, | |
| high energy variation, elevated ZCR. | |
| Typical: stranger approaching, unexpected noise, territorial response. | |
| Key features: high rms_std (spiky energy), many bursts, many pauses, | |
| high zcr, bright centroid. | |
| """ | |
| return ( | |
| 0.30 * _ramp(nf["rms_std"], 0.50) # spiky — not sustained | |
| + 0.25 * _ramp(nf["burst"], 0.40) # lots of short bursts | |
| + 0.20 * _ramp(nf["zcr"], 0.50) # sharp, high-frequency | |
| + 0.15 * _ramp(nf["centroid"], 0.55) # bright tone | |
| + 0.10 * _ramp(nf["pause"], 0.30) # silences between barks | |
| ) | |
| def _score_anxious(nf: dict) -> float: | |
| """ | |
| Anxious bark: chaotic irregular pattern, variable energy, many pauses | |
| mixed with whimper-like tonal fragments, unstable pitch. | |
| Typical: separation anxiety, thunderstorm fear, vet visit. | |
| Key features: high beat_reg (irregular), high rms_std, many pauses, | |
| high zcr_std (tone keeps changing). | |
| """ | |
| return ( | |
| 0.30 * _ramp(nf["beat_reg"], 0.55) # irregular rhythm | |
| + 0.25 * _ramp(nf["rms_std"], 0.45) # variable energy | |
| + 0.25 * _ramp(nf["pause"], 0.40) # lots of gaps / hesitations | |
| + 0.20 * _ramp(nf["zcr_std"], 0.50) # chaotic tone | |
| ) | |
| def _score_warning(nf: dict) -> float: | |
| """ | |
| Warning / threat bark: sustained loud energy, low-pitched, deliberate | |
| and slow — more of a "woof" than rapid-fire barking. | |
| Typical: perceived intruder, territorial boundary warning. | |
| Key features: high rms (sustained), low f0 (low-pitched), low beat_reg | |
| (regular slow pattern), low centroid (dark tone), few pauses. | |
| """ | |
| low_pitch = _ramp(nf["f0"], 0.40, flip=True) # low fundamental freq | |
| low_bright = _ramp(nf["centroid"], 0.45, flip=True) # dark tone | |
| return ( | |
| 0.30 * _ramp(nf["rms"], 0.55) # sustained loudness | |
| + 0.25 * low_pitch # low fundamental frequency | |
| + 0.20 * low_bright # darker spectral content | |
| + 0.15 * _ramp(nf["beat_reg"], 0.45, flip=True) # deliberate, regular | |
| + 0.10 * _ramp(nf["pause"], 0.30, flip=True) # few gaps — sustained | |
| ) | |
| # Dispatch table — keeps _score_all() clean and makes adding moods trivial | |
| _SCORERS = { | |
| "excited": _score_excited, | |
| "playful": _score_playful, | |
| "alert": _score_alert, | |
| "anxious": _score_anxious, | |
| "warning": _score_warning, | |
| } | |
| # ============================================================================= | |
| # Winner selection, veto, confidence, explanation | |
| # ============================================================================= | |
| def _pick_winner(nf: dict, scores: dict, raw: dict) -> tuple: | |
| """ | |
| Choose the winning mood, apply veto rules, compute confidence, build explanation. | |
| Returns (mood, confidence_int, explanation_str). | |
| """ | |
| # Sort moods from highest to lowest score | |
| ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True) | |
| winner_mood, winner_score = ranked[0] | |
| runner_mood, runner_score = ranked[1] | |
| # --- Veto checks: block a mood if a hard required condition is not met --- | |
| vetoed = _check_veto(winner_mood, nf) | |
| if vetoed: | |
| # Demote winner; try runner-up (apply veto to that too, just in case) | |
| if not _check_veto(runner_mood, nf): | |
| winner_mood, winner_score = runner_mood, runner_score | |
| # Third-place score for margin calculation (safe even with < 3 moods) | |
| runner_score = ranked[2][1] if len(ranked) > 2 else 0.0 | |
| else: | |
| winner_mood, winner_score = "unknown", 0.0 | |
| runner_score = 0.0 | |
| # --- Confidence: blend of absolute score + margin over runner-up --- | |
| margin = winner_score - runner_score | |
| confidence = int( | |
| np.clip( | |
| winner_score * CONF_SCORE_WEIGHT + margin * CONF_MARGIN_WEIGHT * 2, | |
| 0, 100 | |
| ) | |
| ) | |
| # Suppress if score is too low to trust | |
| if winner_score < 0.20: | |
| winner_mood = "unknown" | |
| confidence = max(confidence, 5) # at least something, not zero | |
| explanation = _build_explanation(winner_mood, nf, scores, raw) | |
| return winner_mood, confidence, explanation | |
| def _check_veto(mood: str, nf: dict) -> bool: | |
| """ | |
| Return True if the mood should be vetoed based on hard conditions. | |
| These are intentionally narrow — only block when clearly wrong. | |
| """ | |
| if mood == "excited" and nf["rms"] < VETO_EXCITED_MIN_RMS: | |
| return True # excited bark must be genuinely loud | |
| if mood == "warning": | |
| if nf["rms"] < VETO_WARNING_MIN_RMS: | |
| return True # warning bark must have real volume | |
| if nf["f0"] > VETO_WARNING_MAX_F0: | |
| return True # warning bark must be relatively low-pitched | |
| if mood == "playful" and nf["rms"] > VETO_PLAYFUL_MAX_RMS: | |
| return True # a near-maximal roar is not playful | |
| if mood == "anxious" and nf["rms_std"] < VETO_ANXIOUS_MIN_VAR: | |
| return True # anxious bark must have some energy variation | |
| return False | |
| # ============================================================================= | |
| # Explanation builder | |
| # ============================================================================= | |
| # Qualitative descriptors used in explanations — indexed by normalised value | |
| _LEVELS = { | |
| "rms": [(0.35, "quiet"), (0.65, "moderate"), (1.0, "loud")], | |
| "rms_std": [(0.35, "steady"), (0.65, "variable"), (1.0, "spiky")], | |
| "zcr": [(0.35, "tonal"), (0.65, "mixed-tone"), (1.0, "noisy")], | |
| "zcr_std": [(0.35, "consistent"),(0.65, "shifting"), (1.0, "chaotic")], | |
| "centroid": [(0.35, "dark"), (0.65, "mid-range"), (1.0, "bright")], | |
| "beat_reg": [(0.35, "regular"), (0.65, "loose"), (1.0, "irregular")], | |
| "tempo": [(0.35, "slow"), (0.65, "moderate"), (1.0, "fast")], | |
| "f0": [(0.35, "low-pitch"), (0.65, "mid-pitch"), (1.0, "high-pitch")], | |
| "burst": [(0.35, "few bursts"),(0.65, "some bursts"),(1.0, "many bursts")], | |
| "pause": [(0.35, "few pauses"),(0.65, "some pauses"),(1.0, "many pauses")], | |
| } | |
| # Which features matter most for each mood, in priority order | |
| _MOOD_FEATURES = { | |
| "excited": ["rms", "burst", "zcr", "centroid"], | |
| "playful": ["beat_reg","rms", "burst", "zcr"], | |
| "alert": ["rms_std", "burst", "zcr", "centroid"], | |
| "anxious": ["beat_reg","rms_std", "pause", "zcr_std"], | |
| "warning": ["rms", "f0", "centroid","beat_reg"], | |
| "unknown": ["rms", "rms_std", "zcr"], | |
| } | |
| # Short phrases describing what each feature's level means for that mood | |
| _MOOD_FEATURE_PHRASES = { | |
| "excited": { | |
| "rms": {"quiet": "low energy for an excited bark", | |
| "moderate": "decent energy level", | |
| "loud": "strong loud energy"}, | |
| "burst": {"few bursts": "bark pattern is sparse", | |
| "some bursts": "moderate burst activity", | |
| "many bursts": "rapid-fire burst pattern"}, | |
| "zcr": {"tonal": "relatively smooth tone", | |
| "mixed-tone": "mixed harmonic content", | |
| "noisy": "dense noisy texture"}, | |
| "centroid": {"dark": "darker tonal quality", | |
| "mid-range": "mid-range brightness", | |
| "bright": "bright high-frequency content"}, | |
| }, | |
| "playful": { | |
| "beat_reg": {"regular": "nicely rhythmic pattern", | |
| "loose": "loosely rhythmic", | |
| "irregular": "quite irregular rhythm"}, | |
| "rms": {"quiet": "gentle energy", | |
| "moderate": "energetic but not intense", | |
| "loud": "high energy"}, | |
| "burst": {"few bursts": "few energy spikes", | |
| "some bursts": "playful burst pattern", | |
| "many bursts": "lots of activity"}, | |
| "zcr": {"tonal": "pure, clear tone", | |
| "mixed-tone": "typical bark texture", | |
| "noisy": "rough textured bark"}, | |
| }, | |
| "alert": { | |
| "rms_std": {"steady": "energy is even — not spiky", | |
| "variable": "noticeable energy variation", | |
| "spiky": "sharp energy spikes"}, | |
| "burst": {"few bursts": "not many sudden bursts", | |
| "some bursts": "some sharp bursts", | |
| "many bursts": "rapid sharp burst pattern"}, | |
| "zcr": {"tonal": "relatively smooth", | |
| "mixed-tone": "mixed tone content", | |
| "noisy": "sharp high-frequency content"}, | |
| "centroid": {"dark": "darker than typical alert bark", | |
| "mid-range": "moderate brightness", | |
| "bright": "sharp bright tone"}, | |
| }, | |
| "anxious": { | |
| "beat_reg": {"regular": "surprisingly regular for anxious", | |
| "loose": "somewhat irregular rhythm", | |
| "irregular": "very chaotic rhythm"}, | |
| "rms_std": {"steady": "unusually steady energy", | |
| "variable": "noticeable energy swings", | |
| "spiky": "very erratic energy"}, | |
| "pause": {"few pauses": "few hesitations", | |
| "some pauses": "gaps and hesitations present", | |
| "many pauses": "lots of stops and starts"}, | |
| "zcr_std": {"consistent": "stable tone", | |
| "shifting": "shifting tone character", | |
| "chaotic": "highly unstable tone"}, | |
| }, | |
| "warning": { | |
| "rms": {"quiet": "quieter than a typical warning", | |
| "moderate": "solid sustained volume", | |
| "loud": "powerful sustained volume"}, | |
| "f0": {"low-pitch": "deep low-pitched bark", | |
| "mid-pitch": "moderate pitch", | |
| "high-pitch": "higher-pitched than typical warning"}, | |
| "centroid": {"dark": "dark low-frequency tone", | |
| "mid-range": "moderate tonal quality", | |
| "bright": "brighter than typical warning"}, | |
| "beat_reg": {"regular": "slow deliberate pattern", | |
| "loose": "mostly deliberate", | |
| "irregular": "more chaotic than typical warning"}, | |
| }, | |
| "unknown": { | |
| "rms": {"quiet": "very low energy", "moderate": "moderate energy", | |
| "loud": "high energy"}, | |
| "rms_std": {"steady": "steady", "variable": "variable", "spiky": "erratic"}, | |
| "zcr": {"tonal": "tonal", "mixed-tone": "mixed", "noisy": "noisy"}, | |
| }, | |
| } | |
| def _build_explanation(mood: str, nf: dict, scores: dict, raw: dict) -> str: | |
| """ | |
| Produce a plain-English sentence explaining the top contributing features. | |
| """ | |
| if mood == "unknown": | |
| return ( | |
| "The audio features don't strongly match any known bark pattern — " | |
| f"top scores were {_top_scores_str(scores)}. " | |
| "Try a cleaner recording with less background noise." | |
| ) | |
| feature_keys = _MOOD_FEATURES.get(mood, list(nf.keys())[:3]) | |
| phrases = _MOOD_FEATURE_PHRASES.get(mood, {}) | |
| parts = [] | |
| for key in feature_keys[:3]: # use top 3 features in the explanation | |
| level = _level_label(key, nf.get(key, 0.0)) | |
| phrase = phrases.get(key, {}).get(level, f"{key} is {level}") | |
| parts.append(phrase) | |
| # Extra context for burst/pause counts | |
| burst_n = raw.get("burst_count", 0) | |
| pause_n = raw.get("pause_count", 0) | |
| rhythm_note = "" | |
| if mood in ("alert", "excited") and burst_n > 0: | |
| rhythm_note = f" ({burst_n} burst{'s' if burst_n != 1 else ''} detected)" | |
| elif mood == "anxious" and pause_n > 0: | |
| rhythm_note = f" ({pause_n} pause{'s' if pause_n != 1 else ''} detected)" | |
| runner_up = _runner_up(mood, scores) | |
| runner_note = f" Closest alternative was '{runner_up}'." if runner_up else "" | |
| return ( | |
| f"Classified as {mood}: {'; '.join(parts)}{rhythm_note}.{runner_note}" | |
| ) | |
| def _runner_up(winner: str, scores: dict) -> str: | |
| """Return the mood with the second-highest score.""" | |
| others = {m: s for m, s in scores.items() if m != winner} | |
| if not others: | |
| return "" | |
| return max(others, key=others.get) | |
| def _top_scores_str(scores: dict) -> str: | |
| """Format top 2 scores for display in the unknown explanation.""" | |
| top = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:2] | |
| return ", ".join(f"{m} ({s:.2f})" for m, s in top) | |
| # ============================================================================= | |
| # Utility helpers | |
| # ============================================================================= | |
| def _level_label(feature_key: str, norm_value: float) -> str: | |
| """Map a normalised feature value to its qualitative label.""" | |
| levels = _LEVELS.get(feature_key, [(0.5, "low"), (1.0, "high")]) | |
| for threshold, label in levels: | |
| if norm_value <= threshold: | |
| return label | |
| return levels[-1][1] | |
| def _qual(norm_value: float, labels: tuple) -> str: | |
| """ | |
| Map a 0–1 normalised value to one of three qualitative labels. | |
| Used for the feature_summary dict shown in the UI. | |
| """ | |
| lo, mid, hi = labels | |
| if norm_value < 0.35: | |
| return lo | |
| if norm_value < 0.65: | |
| return mid | |
| return hi | |