Spaces:
Sleeping
Sleeping
| """ | |
| predictor_audio.py — Replaces Component 2 for unpublished videos. | |
| Uses audio energy + pacing to simulate a retention curve. | |
| """ | |
| import numpy as np | |
| import pandas as pd | |
| import json | |
| def generate_simulated_retention(video_path, total_duration): | |
| """ | |
| Analyzes audio energy to simulate a retention curve. | |
| Returns a DataFrame matching get_retention_curve() output format. | |
| """ | |
| try: | |
| import librosa | |
| audio, sr = librosa.load(video_path, sr=22050, mono=True) | |
| # Calculate RMS energy per second | |
| hop_length = sr # 1 second per frame | |
| rms = librosa.feature.rms(y=audio, hop_length=hop_length)[0] | |
| # Normalize energy 0 to 1 | |
| rms_norm = (rms - rms.min()) / (rms.max() - rms.min() + 1e-6) | |
| seconds = np.arange(len(rms_norm)) | |
| except Exception as e: | |
| print(f"[Predictor Audio] librosa failed ({e}), using duration-based fallback") | |
| seconds = np.arange(int(total_duration)) | |
| rms_norm = np.ones(len(seconds)) | |
| # Build simulated retention curve | |
| # Base: natural decay (viewers always drop over time) | |
| n = len(seconds) | |
| base_decay = np.linspace(1.0, 0.30, n) | |
| # Modify decay based on audio energy | |
| # Low energy = viewers more likely to leave | |
| # High energy = viewers stay | |
| energy_factor = 0.15 * (rms_norm - 0.5) # range: -0.075 to +0.075 | |
| # Add intro spike drop (first 30s many viewers leave) | |
| intro_drop = np.ones(n) | |
| intro_end = min(30, n) | |
| intro_drop[:intro_end] = np.linspace(1.0, 0.85, intro_end) | |
| # Combine all factors | |
| retention = base_decay * intro_drop + energy_factor | |
| retention = np.clip(retention, 0.05, 1.0) | |
| # Smooth the curve | |
| window = min(30, n // 4) | |
| if window > 1: | |
| retention = np.convolve(retention, np.ones(window)/window, mode='same') | |
| retention = np.clip(retention, 0.05, 1.0) | |
| df = pd.DataFrame({ | |
| "second": seconds, | |
| "retention_pct": (retention * 100).round(2) | |
| }) | |
| print(f"[Predictor Audio] Simulated retention curve — {len(df)} seconds") | |
| return df | |
| def run_component2_offline(video_path): | |
| """ | |
| Drop-in replacement for run_component2() in pipeline.py. | |
| Uses audio analysis instead of YouTube Analytics API. | |
| """ | |
| with open("candidates.json") as f: | |
| data = json.load(f) | |
| candidates = data["candidates"] | |
| total_duration = data["total_duration"] | |
| # Generate simulated retention curve from audio | |
| curve_df = generate_simulated_retention(video_path, total_duration) | |
| rows = [] | |
| for i, c in enumerate(candidates): | |
| t = c["timestamp"] | |
| mask = (curve_df["second"] >= t - 10) & (curve_df["second"] <= t + 10) | |
| subset = curve_df[mask] | |
| if subset.empty: | |
| ret_at_t, drop_rate, recovery = 50.0, 0.0, 0.0 | |
| else: | |
| idx = (subset["second"] - t).abs().idxmin() | |
| ret_at_t = float(curve_df.loc[idx, "retention_pct"]) | |
| before = curve_df[curve_df["second"] < t].tail(30) | |
| after = curve_df[curve_df["second"] > t].head(30) | |
| further = curve_df[curve_df["second"] > t + 30].head(30) | |
| drop_rate = float(before["retention_pct"].mean() - after["retention_pct"].mean()) if len(before) and len(after) else 0 | |
| recovery = float(after["retention_pct"].mean() - further["retention_pct"].mean()) if len(after) and len(further) else 0 | |
| time_since_last = t - candidates[i-1]["timestamp"] if i > 0 else t | |
| rows.append({ | |
| "timestamp": round(t, 2), | |
| "type": c["type"], | |
| "content_score": c["score"], | |
| "retention_at_t": round(ret_at_t, 3), | |
| "retention_drop_rate": round(drop_rate, 3), | |
| "retention_recovery": round(recovery, 3), | |
| "relative_position": round(t / total_duration, 4), | |
| "time_since_last_candidate": round(time_since_last, 2), | |
| "label": 0 | |
| }) | |
| df = pd.DataFrame(rows) | |
| if len(df) > 1: | |
| df["label"] = ( | |
| (df["retention_at_t"] > df["retention_at_t"].median()) & | |
| (df["retention_drop_rate"] < df["retention_drop_rate"].median()) | |
| ).astype(int) | |
| df.to_csv("features.csv", index=False) | |
| print(f"[Predictor Audio] features.csv saved — {len(df)} candidates") | |
| return df |