"""Measured patterns across recordings (no AI). * A comparison table of headline metrics. * Pairwise acoustic similarity from three components that work even with two files (no cross-file standardisation): timbre (MFCC distance scaled by the recordings' own variability), spectral balance (Hellinger affinity of band energies) and voice pitch (overlap of pitch histograms). * Pairwise content similarity (TF-IDF cosine of transcripts), shared and distinctive vocabulary. * With 3+ files: closest pair / most distinct file and monotonic trends over recording dates; with 4+: robust outliers and similarity groups. """ from __future__ import annotations import itertools import re import numpy as np METRICS = [ # key, label, unit, decimals, higher-is (for wording only) ("duration_min", "Duration", "min", 1), ("lufs", "Loudness", "LUFS", 1), ("snr_db", "Signal-to-noise (est.)", "dB", 0), ("activity_pct", "Sound activity", "%", 0), ("pauses_per_min", "Pauses (0.5 s+) per minute", "/min", 1), ("longest_pause_s", "Longest pause", "s", 1), ("pitch_median_hz", "Median voice pitch", "Hz", 0), ("centroid_hz", "Spectral centroid", "Hz", 0), ("bandwidth_khz", "Effective bandwidth", "kHz", 1), ("words_per_minute", "Speaking rate", "words/min", 0), ("speakers", "Speakers", "", 0), ("dominant_share_pct", "Main speaker's talk share", "%", 0), ("turns_per_minute", "Speaker turns per minute", "/min", 1), ("questions", "Questions asked", "", 0), ("fillers_per_100", "Fillers per 100 words", "", 1), ("lexical_diversity", "Vocabulary variety (MATTR)", "", 2), ("sentiment_score", "Sentiment (AI, -1 to +1)", "", 2), ] METRIC_INFO = {k: (label, unit, dec) for k, label, unit, dec in METRICS} STOP_EXTRA = {"um", "uh", "erm", "er", "uhm", "umm", "hmm", "mm", "mhm", "ah", "eh", "yeah", "okay", "ok", "like", "just", "really", "know", "think", "going", "gonna", "kind", "sort", "thing", "things", "right", "lot", "actually", "mean", "maybe", "got", "get", "inaudible", "laughs", "laughter", "don", "didn", "doesn", "isn", "wasn", "aren", "weren", "couldn", "wouldn", "shouldn", "won", "ain", "say", "said", "says", "tell", "told", "come", "came", "did", "does", "having", "trying", "want", "wanted", "sorry", "thank", "thanks", "guess", "yes", "sure", "pretty", "stuff", "feel", "feels", "went", "goes", "make", "made", "way", "good", "great", "little", "bit", "able", "look", "need"} def metric_values(rec: dict) -> dict: ac, sm, an = rec.get("acoustics"), rec.get("speech") or {}, rec.get("analysis") or {} v: dict = {} if ac is not None: v["duration_min"] = ac.duration_s / 60 v["lufs"] = ac.integrated_lufs v["snr_db"] = None if ac.steady_signal else ac.snr_db v["activity_pct"] = ac.activity_ratio * 100 v["pauses_per_min"] = ac.pauses_per_min v["longest_pause_s"] = ac.longest_pause_s v["pitch_median_hz"] = ac.pitch_median_hz v["centroid_hz"] = ac.spectral_centroid_hz v["bandwidth_khz"] = ac.bandwidth_hz / 1000 if sm: v["words_per_minute"] = sm.get("words_per_minute") v["speakers"] = sm.get("speakers") v["dominant_share_pct"] = sm["dominant_speaker_share"] * 100 if sm.get("dominant_speaker_share") is not None else None v["turns_per_minute"] = sm.get("turns_per_minute") v["questions"] = sm.get("questions") v["fillers_per_100"] = sm.get("fillers_per_100_words") v["lexical_diversity"] = sm.get("lexical_diversity") sent = an.get("sentiment") if isinstance(an, dict) else None if sent and isinstance(sent.get("score"), (int, float)): v["sentiment_score"] = max(-1.0, min(1.0, float(sent["score"]))) return {k: (float(x) if x is not None and np.isfinite(x) else None) for k, x in v.items()} # --- acoustic similarity -------------------------------------------------------- def _timbre(a, b) -> float: ma, mb = np.array(a.mfcc_mean[1:13]), np.array(b.mfcc_mean[1:13]) sa, sb = np.array(a.mfcc_std[1:13]), np.array(b.mfcc_std[1:13]) pooled = np.sqrt((sa ** 2 + sb ** 2) / 2 + 1e-9) d = float(np.sqrt(np.mean(((ma - mb) / pooled) ** 2))) return float(np.exp(-d)) def _spectral(a, b) -> float: p = np.array(list(a.band_fractions.values())) q = np.array(list(b.band_fractions.values())) bc = float(np.sum(np.sqrt(np.clip(p, 0, None) * np.clip(q, 0, None)))) return float(1 - np.sqrt(max(0.0, 1 - bc))) def _pitch(a, b) -> float | None: if not a.pitch_hist or not b.pitch_hist: return None return float(np.minimum(np.array(a.pitch_hist), np.array(b.pitch_hist)).sum()) def acoustic_similarity(recs: list[dict]) -> dict: n = len(recs) comps = {"timbre": np.full((n, n), np.nan), "spectral balance": np.full((n, n), np.nan), "voice pitch": np.full((n, n), np.nan)} for i, j in itertools.product(range(n), range(n)): a, b = recs[i]["acoustics"], recs[j]["acoustics"] if i == j: for m in comps.values(): m[i, j] = 1.0 continue if j < i: continue vals = {"timbre": _timbre(a, b), "spectral balance": _spectral(a, b), "voice pitch": _pitch(a, b)} for k, v in vals.items(): if v is not None: comps[k][i, j] = comps[k][j, i] = v stack = np.stack(list(comps.values())) with np.errstate(all="ignore"): overall = np.nanmean(stack, axis=0) return {"overall": overall, "components": comps} # --- content similarity ----------------------------------------------------------- def content_similarity(texts: list[str]) -> dict | None: """TF-IDF cosine similarity plus shared / distinctive terms (2+ transcripts).""" if sum(1 for t in texts if len(t.split()) >= 30) < 2: return None from sklearn.feature_extraction.text import ENGLISH_STOP_WORDS, CountVectorizer, TfidfTransformer stop = sorted(set(ENGLISH_STOP_WORDS) | STOP_EXTRA) cleaned = [re.sub(r"\[[^\]]*\]", " ", t) for t in texts] cv = CountVectorizer(stop_words=stop, token_pattern=r"(?u)\b[^\W\d_]{3,}\b", ngram_range=(1, 2), max_features=30000, lowercase=True) try: counts = cv.fit_transform(cleaned) except ValueError: # empty vocabulary return None terms = np.array(cv.get_feature_names_out()) tfidf = TfidfTransformer(sublinear_tf=True).fit_transform(counts) sim = (tfidf @ tfidf.T).toarray() dense = counts.toarray() df = (dense > 0).sum(axis=0) n = len(texts) shared = [] if n >= 2: mask = df >= 2 order = np.argsort(-(dense[:, mask].sum(axis=0))) idx = np.flatnonzero(mask)[order][:25] shared = [{"term": terms[i], "files": int(df[i]), "count": int(dense[:, i].sum())} for i in idx] distinctive = [] tf_dense = tfidf.toarray() for d in range(n): own = np.flatnonzero((dense[d] >= 2) & (df == 1)) pool = own if own.size >= 5 else np.flatnonzero(dense[d] >= 2) if pool.size == 0: pool = np.flatnonzero(dense[d] > 0) top = pool[np.argsort(-tf_dense[d, pool])][:12] distinctive.append([terms[i] for i in top]) return {"matrix": sim, "shared_terms": shared, "distinctive_terms": distinctive} # --- trends / outliers / groups ------------------------------------------------------ def trends(recs: list[dict], values: list[dict]) -> list[dict]: dated = [(r["info"].recorded_at, v, r["id"]) for r, v in zip(recs, values) if r["info"].recorded_at] if len(dated) < 3: return [] dated.sort(key=lambda x: x[0]) out = [] for key, label, unit, dec in METRICS: series = [(d, v.get(key), fid) for d, v, fid in dated if v.get(key) is not None] if len(series) < 3: continue ys = np.array([s[1] for s in series]) if np.ptp(ys) == 0: continue diffs = np.diff(ys) if np.all(diffs > 0) or np.all(diffs < 0): from scipy.stats import spearmanr rho = float(spearmanr(np.arange(len(ys)), ys).statistic) out.append({ "metric": key, "label": label, "unit": unit, "direction": "increases" if diffs[0] > 0 else "decreases", "rho": round(rho, 2), "n": len(ys), "first": round(float(ys[0]), dec), "last": round(float(ys[-1]), dec), "file_ids": [s[2] for s in series], }) return out def outliers(recs: list[dict], values: list[dict]) -> list[dict]: if len(recs) < 4: return [] out = [] for key, label, unit, dec in METRICS: pairs = [(r["id"], v.get(key)) for r, v in zip(recs, values) if v.get(key) is not None] if len(pairs) < 4: continue xs = np.array([p[1] for p in pairs]) med = float(np.median(xs)) mad = float(np.median(np.abs(xs - med))) if mad <= 1e-9: continue for fid, x in pairs: z = 0.6745 * (x - med) / mad if abs(z) > 3.5: out.append({"file_id": fid, "metric": key, "label": label, "unit": unit, "value": round(x, dec), "median": round(med, dec), "z": round(float(z), 1)}) return out def groups(ids: list[str], sim: np.ndarray) -> list[list[str]]: n = len(ids) if n < 4 or not np.isfinite(sim).all(): return [] from scipy.cluster.hierarchy import fcluster, linkage from scipy.spatial.distance import squareform dist = np.clip(1 - sim, 0, None) np.fill_diagonal(dist, 0) z = linkage(squareform(dist, checks=False), method="average") heights = z[:, 2] gaps = np.diff(heights) if gaps.size == 0 or gaps.max() < 0.05: return [] cut = heights[int(np.argmax(gaps))] + gaps.max() / 2 labels = fcluster(z, t=cut, criterion="distance") out = [[ids[i] for i in range(n) if labels[i] == g] for g in sorted(set(labels))] if len(out) < 2 or len(out) == n: return [] return sorted(out, key=len, reverse=True) # --- main --------------------------------------------------------------------------------- def compare(recs: list[dict]) -> dict: """recs: successful files [{id, info, acoustics, speech, analysis, listening}].""" ids = [r["id"] for r in recs] values = [metric_values(r) for r in recs] table = [] for key, label, unit, dec in METRICS: row = {fid: v.get(key) for fid, v in zip(ids, values)} if any(x is not None for x in row.values()): table.append({"key": key, "label": label, "unit": unit, "decimals": dec, "values": row}) result: dict = {"ids": ids, "table": table, "values": values} if len(recs) < 2: return result ac = acoustic_similarity(recs) result["acoustic"] = ac texts = [r["listening"].transcript_text() if r.get("listening") else "" for r in recs] content = content_similarity([re.sub(r"^\[[^\]]*\] [^:]*: ", "", t, flags=re.M) for t in texts]) result["content"] = content combined = ac["overall"].copy() if content is not None: with np.errstate(all="ignore"): combined = np.nanmean(np.stack([ac["overall"], content["matrix"]]), axis=0) result["combined"] = combined n = len(ids) pairs = [(combined[i, j], ids[i], ids[j]) for i in range(n) for j in range(i + 1, n) if np.isfinite(combined[i, j])] if n >= 3 and pairs: best = max(pairs) result["closest_pair"] = {"ids": [best[1], best[2]], "similarity": round(float(best[0]), 3)} with np.errstate(all="ignore"): mean_sim = [(np.nanmean([combined[i, j] for j in range(n) if j != i]), ids[i]) for i in range(n)] low = min(mean_sim) result["most_distinct"] = {"id": low[1], "mean_similarity": round(float(low[0]), 3)} result["trends"] = trends(recs, values) result["outliers"] = outliers(recs, values) result["groups"] = groups(ids, combined) return result def brief(cmp: dict) -> str: """Plain-text version for the synthesis prompt.""" ids = cmp["ids"] lines = ["metric | " + " | ".join(ids)] for row in cmp["table"]: vals = [] for fid in ids: x = row["values"].get(fid) vals.append("n/a" if x is None else f"{x:.{row['decimals']}f}") unit = f" ({row['unit']})" if row["unit"] else "" lines.append(f"{row['label']}{unit} | " + " | ".join(vals)) if "acoustic" in cmp: n = len(ids) sims = [] for i in range(n): for j in range(i + 1, n): a = cmp["acoustic"]["overall"][i, j] c = cmp["content"]["matrix"][i, j] if cmp.get("content") else None s = f"{ids[i]}-{ids[j]}: acoustic similarity {a:.2f}" if c is not None: s += f", transcript vocabulary similarity {c:.2f}" sims.append(s) lines.append("Pairwise similarity (0-1): " + "; ".join(sims[:60])) if cmp.get("content") and cmp["content"]["shared_terms"]: lines.append("Terms shared by several transcripts: " + ", ".join(t["term"] for t in cmp["content"]["shared_terms"][:20])) for t in cmp.get("trends", []): lines.append(f"Trend over recording dates: {t['label']} {t['direction']} ({t['first']} -> {t['last']}, n={t['n']})") for o in cmp.get("outliers", []): lines.append(f"Outlier: {o['file_id']} {o['label']} = {o['value']} vs median {o['median']}") if cmp.get("groups"): lines.append("Similarity groups: " + " | ".join(", ".join(g) for g in cmp["groups"])) return "\n".join(lines)