|
|
import os |
|
|
import tempfile |
|
|
import numpy as np |
|
|
import pandas as pd |
|
|
import gradio as gr |
|
|
|
|
|
import joblib |
|
|
import soundfile as sf |
|
|
from pydub import AudioSegment |
|
|
import opensmile |
|
|
|
|
|
import freesound |
|
|
import xgboost as xgb |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
MIN_EFFECT, MAX_EFFECT = 0.5, 3.0 |
|
|
MIN_MUSIC, MAX_MUSIC = 10.0, 60.0 |
|
|
SR_TARGET = 16000 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CSS = """ |
|
|
.card { |
|
|
border: 1px solid #e5e7eb; |
|
|
background: #ffffff; |
|
|
padding: 16px; |
|
|
border-radius: 16px; |
|
|
} |
|
|
.card-error{ |
|
|
border-color: #fca5a5; |
|
|
background: #fff1f2; |
|
|
} |
|
|
.card-title{ |
|
|
font-weight: 950; |
|
|
margin-bottom: 8px; |
|
|
} |
|
|
.badges{ |
|
|
display:flex; |
|
|
gap:10px; |
|
|
flex-wrap:wrap; |
|
|
margin-bottom:12px; |
|
|
} |
|
|
.badge{ |
|
|
padding:6px 10px; |
|
|
border-radius:999px; |
|
|
font-weight:900; |
|
|
font-size: 13px; |
|
|
border: 1px solid #e5e7eb; |
|
|
} |
|
|
.badge-type{ background:#eef2ff; color:#3730a3;} |
|
|
.badge-time{ background:#ecfeff; color:#155e75;} |
|
|
|
|
|
.grid{ |
|
|
display:grid; |
|
|
grid-template-columns: 1fr; |
|
|
gap:10px; |
|
|
} |
|
|
.box{ |
|
|
border:1px solid #e5e7eb; |
|
|
border-radius:14px; |
|
|
padding:12px; |
|
|
background:#fafafa; |
|
|
} |
|
|
.box-title{ font-weight:900; margin-bottom:4px; } |
|
|
.box-value{ font-size:18px; font-weight:800; } |
|
|
|
|
|
.hint{ |
|
|
margin-top:10px; |
|
|
color:#6b7280; |
|
|
font-size:12px; |
|
|
} |
|
|
|
|
|
#header-title { font-size: 28px; font-weight: 950; margin-bottom: 6px; } |
|
|
#header-sub { color:#6b7280; margin-top:0px; line-height:1.45; } |
|
|
""" |
|
|
|
|
|
|
|
|
def html_error(title, body_html): |
|
|
return f""" |
|
|
<div class="card card-error"> |
|
|
<div class="card-title">❌ {title}</div> |
|
|
<div>{body_html}</div> |
|
|
</div> |
|
|
""".strip() |
|
|
|
|
|
|
|
|
def html_result(badge_text, duration, rating_text, downloads_text, extra_html=""): |
|
|
return f""" |
|
|
<div class="card"> |
|
|
<div class="badges"> |
|
|
<span class="badge badge-type">{badge_text}</span> |
|
|
<span class="badge badge-time">⏱️ {duration:.2f} s</span> |
|
|
</div> |
|
|
|
|
|
<div class="grid"> |
|
|
<div class="box"> |
|
|
<div class="box-title">📈 Popularité de la note moyenne</div> |
|
|
<div class="box-value">{rating_text}</div> |
|
|
</div> |
|
|
<div class="box"> |
|
|
<div class="box-title">⬇️ Popularité des téléchargements</div> |
|
|
<div class="box-value">{downloads_text}</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{extra_html} |
|
|
|
|
|
<div class="hint"> |
|
|
Résultats affichés en <b>niveaux</b> (faible / moyen / élevé), pas en valeurs exactes. |
|
|
</div> |
|
|
</div> |
|
|
""".strip() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def interpret_results(avg_class: int, dl_class: int) -> str: |
|
|
""" |
|
|
avg_class: 0=Missed info, 1=Low, 2=Medium, 3=High |
|
|
dl_class: 0=Low, 1=Medium, 2=High |
|
|
""" |
|
|
if avg_class == 0: |
|
|
return ( |
|
|
"ℹ️ <b>Interprétation</b> :<br>" |
|
|
"Aucune évaluation possible (rating manquant).<br>" |
|
|
|
|
|
) |
|
|
|
|
|
rating_txt = {1: "faible", 2: "moyenne", 3: "élevée"}.get(avg_class, "inconnue") |
|
|
downloads_txt = {0: "faible", 1: "modérée", 2: "élevée"}.get(dl_class, "inconnue") |
|
|
|
|
|
if avg_class == 3 and dl_class == 2: |
|
|
potentiel = "très fort" |
|
|
detail = "contenu de haute qualité et très populaire." |
|
|
elif avg_class == 3 and dl_class == 1: |
|
|
potentiel = "fort" |
|
|
detail = "contenu bien apprécié, en croissance." |
|
|
elif avg_class == 3 and dl_class == 0: |
|
|
potentiel = "prometteur" |
|
|
detail = "bonne qualité mais faible visibilité (peut gagner en popularité)." |
|
|
elif avg_class == 2 and dl_class == 2: |
|
|
potentiel = "modéré à fort" |
|
|
detail = "populaire mais qualité perçue moyenne." |
|
|
elif avg_class == 2 and dl_class == 1: |
|
|
potentiel = "modéré" |
|
|
detail = "profil standard, popularité stable." |
|
|
elif avg_class == 2 and dl_class == 0: |
|
|
potentiel = "limité" |
|
|
detail = "engagement faible, diffusion limitée." |
|
|
elif avg_class == 1 and dl_class == 2: |
|
|
potentiel = "contradictoire" |
|
|
detail = "très téléchargé mais peu apprécié (usage pratique possible)." |
|
|
elif avg_class == 1 and dl_class == 1: |
|
|
potentiel = "faible" |
|
|
detail = "peu attractif pour les utilisateurs." |
|
|
else: |
|
|
potentiel = "très faible" |
|
|
detail = "faible intérêt global." |
|
|
|
|
|
return ( |
|
|
"<b>Interprétation</b> :<br>" |
|
|
f"Potentiel estimé : <b>{potentiel}</b> — {detail}" |
|
|
) |
|
|
|
|
|
|
|
|
def avg_label_to_class(avg_label: str) -> int: |
|
|
""" |
|
|
Convertit un label texte (LabelEncoder) en classe 0..3 : |
|
|
0=Missed info, 1=Low, 2=Medium, 3=High |
|
|
Robuste aux variantes. |
|
|
""" |
|
|
if avg_label is None: |
|
|
return 0 |
|
|
|
|
|
s = str(avg_label).strip().lower() |
|
|
|
|
|
if "miss" in s or "missing" in s or "none" in s or "no" in s: |
|
|
return 0 |
|
|
if "high" in s or "élev" in s or "eleve" in s: |
|
|
return 3 |
|
|
if "medium" in s or "moy" in s: |
|
|
return 2 |
|
|
if "low" in s or "faibl" in s: |
|
|
return 1 |
|
|
return 0 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
MODEL_EFFECT = joblib.load("xgb_model_EffectSound.pkl") |
|
|
MODEL_MUSIC = joblib.load("xgb_model_Music.pkl") |
|
|
|
|
|
RATING_DISPLAY_AUDIO = { |
|
|
0: "❌ Informations manquantes", |
|
|
1: "⭐ Faible", |
|
|
2: "⭐⭐ Moyen", |
|
|
3: "⭐⭐⭐ Élevé", |
|
|
} |
|
|
DOWNLOADS_DISPLAY_AUDIO = { |
|
|
0: "⭐ Faible", |
|
|
1: "⭐⭐ Moyen", |
|
|
2: "⭐⭐⭐ Élevé", |
|
|
} |
|
|
|
|
|
SMILE = opensmile.Smile( |
|
|
feature_set=opensmile.FeatureSet.eGeMAPSv02, |
|
|
feature_level=opensmile.FeatureLevel.Functionals, |
|
|
) |
|
|
|
|
|
|
|
|
def get_duration_seconds(filepath): |
|
|
ext = os.path.splitext(filepath)[1].lower() |
|
|
if ext == ".mp3": |
|
|
audio = AudioSegment.from_file(filepath) |
|
|
return len(audio) / 1000.0 |
|
|
with sf.SoundFile(filepath) as f: |
|
|
return len(f) / f.samplerate |
|
|
|
|
|
|
|
|
def to_wav_16k_mono(filepath): |
|
|
ext = os.path.splitext(filepath)[1].lower() |
|
|
if ext == ".wav": |
|
|
try: |
|
|
with sf.SoundFile(filepath) as f: |
|
|
if f.samplerate == SR_TARGET and f.channels == 1: |
|
|
return filepath |
|
|
except Exception: |
|
|
pass |
|
|
|
|
|
audio = AudioSegment.from_file(filepath) |
|
|
audio = audio.set_channels(1).set_frame_rate(SR_TARGET) |
|
|
|
|
|
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) |
|
|
tmp.close() |
|
|
audio.export(tmp.name, format="wav") |
|
|
return tmp.name |
|
|
|
|
|
|
|
|
def extract_opensmile_features(filepath): |
|
|
wav_path = to_wav_16k_mono(filepath) |
|
|
feats = SMILE.process_file(wav_path) |
|
|
feats = feats.select_dtypes(include=[np.number]).reset_index(drop=True) |
|
|
return feats |
|
|
|
|
|
|
|
|
def predict_upload_with_dmatrix(model, X_df: pd.DataFrame): |
|
|
""" |
|
|
Résout 'data did not contain feature names' en passant via Booster + DMatrix(feature_names=...). |
|
|
Retour: array shape (1, n_outputs) |
|
|
""" |
|
|
if hasattr(model, "estimators_"): |
|
|
preds = [] |
|
|
for est in model.estimators_: |
|
|
booster = est.get_booster() if hasattr(est, "get_booster") else est |
|
|
dm = xgb.DMatrix(X_df.values, feature_names=list(X_df.columns)) |
|
|
p = booster.predict(dm) |
|
|
preds.append(np.asarray(p).reshape(-1)) |
|
|
return np.column_stack(preds) |
|
|
|
|
|
booster = model.get_booster() if hasattr(model, "get_booster") else model |
|
|
dm = xgb.DMatrix(X_df.values, feature_names=list(X_df.columns)) |
|
|
p = booster.predict(dm) |
|
|
return np.asarray(p).reshape(1, -1) |
|
|
|
|
|
|
|
|
def predict_from_uploaded_audio(audio_file): |
|
|
if audio_file is None: |
|
|
return html_error("Aucun fichier", "Veuillez importer un fichier audio (wav, mp3, flac…).") |
|
|
|
|
|
|
|
|
try: |
|
|
duration = get_duration_seconds(audio_file) |
|
|
except Exception as e: |
|
|
return html_error("Audio illisible", f"Impossible de lire l'audio.<br>Détail : <code>{e}</code>") |
|
|
|
|
|
|
|
|
if duration < MIN_EFFECT: |
|
|
return html_error( |
|
|
"Audio trop court", |
|
|
f"Durée détectée : <b>{duration:.2f} s</b><br><br>" |
|
|
f"Plages acceptées :<br>" |
|
|
f"• Effet sonore : <b>{MIN_EFFECT}–{MAX_EFFECT} s</b><br>" |
|
|
f"• Musique : <b>{MIN_MUSIC}–{MAX_MUSIC} s</b>" |
|
|
) |
|
|
|
|
|
if (MAX_EFFECT < duration < MIN_MUSIC) or duration > MAX_MUSIC: |
|
|
return html_error( |
|
|
"Audio hors plage", |
|
|
f"Durée détectée : <b>{duration:.2f} s</b><br><br>" |
|
|
f"Plages acceptées :<br>" |
|
|
f"• Effet sonore : <b>{MIN_EFFECT}–{MAX_EFFECT} s</b><br>" |
|
|
f"• Musique : <b>{MIN_MUSIC}–{MAX_MUSIC} s</b>" |
|
|
) |
|
|
|
|
|
|
|
|
if duration <= MAX_EFFECT: |
|
|
badge = "🔊 Effet sonore (upload)" |
|
|
model = MODEL_EFFECT |
|
|
else: |
|
|
badge = "🎵 Musique (upload)" |
|
|
model = MODEL_MUSIC |
|
|
|
|
|
|
|
|
try: |
|
|
X = extract_opensmile_features(audio_file) |
|
|
except Exception as e: |
|
|
return html_error("Extraction openSMILE échouée", f"Détail : <code>{e}</code>") |
|
|
|
|
|
|
|
|
try: |
|
|
expected = model.estimators_[0].feature_names_in_ if hasattr(model, "estimators_") else model.feature_names_in_ |
|
|
X = X.reindex(columns=list(expected), fill_value=0) |
|
|
except Exception as e: |
|
|
return html_error("Alignement des features échoué", f"Détail : <code>{e}</code>") |
|
|
|
|
|
|
|
|
try: |
|
|
y = predict_upload_with_dmatrix(model, X) |
|
|
except Exception as e: |
|
|
return html_error("Prédiction échouée", f"Détail : <code>{e}</code>") |
|
|
|
|
|
y = np.array(y) |
|
|
avg_class = int(y[0, 0]) |
|
|
dl_class = int(y[0, 1]) |
|
|
|
|
|
rating_text = RATING_DISPLAY_AUDIO.get(avg_class, "Inconnu") |
|
|
downloads_text = DOWNLOADS_DISPLAY_AUDIO.get(dl_class, "Inconnu") |
|
|
|
|
|
conclusion = interpret_results(avg_class, dl_class) |
|
|
extra = f""" |
|
|
<div style="margin-top:12px; padding-top:10px; border-top:1px dashed #d1d5db"> |
|
|
{conclusion} |
|
|
</div> |
|
|
""" |
|
|
|
|
|
return html_result(badge, duration, rating_text, downloads_text, extra_html=extra) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
API_TOKEN = "zE9NjEOgUMzH9K7mjiGBaPJiNwJLjSM53LevarRK" |
|
|
|
|
|
fs_client = freesound.FreesoundClient() |
|
|
fs_client.set_token(API_TOKEN, "token") |
|
|
|
|
|
|
|
|
xgb_music_num = joblib.load("xgb_num_downloads_music_model.pkl") |
|
|
xgb_music_feat_num = joblib.load("xgb_num_downloads_music_features.pkl") |
|
|
xgb_music_avg = joblib.load("xgb_avg_rating_music_model.pkl") |
|
|
xgb_music_feat_avg = joblib.load("xgb_avg_rating_music_features.pkl") |
|
|
le_music_avg = joblib.load("xgb_avg_rating_music_label_encoder.pkl") |
|
|
|
|
|
|
|
|
xgb_effect_num = joblib.load("xgb_num_downloads_effectsound_model.pkl") |
|
|
xgb_effect_feat_num = joblib.load("xgb_num_downloads_effectsound_features.pkl") |
|
|
xgb_effect_avg = joblib.load("xgb_avg_rating_effectsound_model.pkl") |
|
|
xgb_effect_feat_avg = joblib.load("xgb_avg_rating_effectsound_features.pkl") |
|
|
le_effect_avg = joblib.load("xgb_avg_rating_effectsound_label_encoder.pkl") |
|
|
|
|
|
NUM_DOWNLOADS_MAP = {0: "Faible", 1: "Moyen", 2: "Élevé"} |
|
|
|
|
|
|
|
|
def safe_float(v): |
|
|
try: |
|
|
return float(v) |
|
|
except Exception: |
|
|
return 0.0 |
|
|
|
|
|
|
|
|
def predict_with_model_fs(model, features_dict, feat_list, label_encoder=None): |
|
|
row = [] |
|
|
for col in feat_list: |
|
|
val = features_dict.get(col, 0) |
|
|
if val is None or isinstance(val, (list, dict)): |
|
|
val = 0 |
|
|
row.append(safe_float(val)) |
|
|
|
|
|
X = pd.DataFrame([row], columns=feat_list) |
|
|
dmatrix = xgb.DMatrix(X.values, feature_names=feat_list) |
|
|
|
|
|
pred_int = int(model.get_booster().predict(dmatrix)[0]) |
|
|
|
|
|
if label_encoder is not None: |
|
|
return label_encoder.inverse_transform([pred_int])[0] |
|
|
return pred_int |
|
|
|
|
|
|
|
|
def predict_from_freesound_url(url: str): |
|
|
if not url or not url.strip(): |
|
|
return html_error("URL vide", "Collez une URL FreeSound du type <code>https://freesound.org/s/123456/</code>") |
|
|
|
|
|
|
|
|
try: |
|
|
sound_id = int(url.rstrip("/").split("/")[-1]) |
|
|
except Exception: |
|
|
return html_error("URL invalide", "Impossible d'extraire l'ID depuis l'URL.") |
|
|
|
|
|
all_features = list(set( |
|
|
xgb_music_feat_num + xgb_music_feat_avg + xgb_effect_feat_num + xgb_effect_feat_avg |
|
|
)) |
|
|
fields = "duration," + ",".join(all_features) |
|
|
|
|
|
try: |
|
|
results = fs_client.search(query="", filter=f"id:{sound_id}", fields=fields) |
|
|
except Exception as e: |
|
|
return html_error("Erreur API FreeSound", f"Détail : <code>{e}</code>") |
|
|
|
|
|
if len(results.results) == 0: |
|
|
return html_error("Son introuvable", "Aucun résultat pour cet ID.") |
|
|
|
|
|
sound = results.results[0] |
|
|
duration = safe_float(sound.get("duration", 0)) |
|
|
|
|
|
|
|
|
if MIN_EFFECT <= duration <= MAX_EFFECT: |
|
|
badge = "🔊 Effet sonore (FreeSound URL)" |
|
|
dl_class = int(predict_with_model_fs(xgb_effect_num, sound, xgb_effect_feat_num)) |
|
|
avg_text = str(predict_with_model_fs(xgb_effect_avg, sound, xgb_effect_feat_avg, le_effect_avg)) |
|
|
dl_text = NUM_DOWNLOADS_MAP.get(dl_class, str(dl_class)) |
|
|
|
|
|
avg_class = avg_label_to_class(avg_text) |
|
|
conclusion = interpret_results(avg_class, dl_class) |
|
|
|
|
|
extra = f""" |
|
|
<div class="hint">ID FreeSound : <b>{sound_id}</b></div> |
|
|
<div style="margin-top:12px; padding-top:10px; border-top:1px dashed #d1d5db"> |
|
|
{conclusion} |
|
|
</div> |
|
|
""" |
|
|
return html_result(badge, duration, avg_text, dl_text, extra_html=extra) |
|
|
|
|
|
|
|
|
if MIN_MUSIC <= duration <= MAX_MUSIC: |
|
|
badge = "🎵 Musique (FreeSound URL)" |
|
|
dl_class = int(predict_with_model_fs(xgb_music_num, sound, xgb_music_feat_num)) |
|
|
avg_text = str(predict_with_model_fs(xgb_music_avg, sound, xgb_music_feat_avg, le_music_avg)) |
|
|
dl_text = NUM_DOWNLOADS_MAP.get(dl_class, str(dl_class)) |
|
|
|
|
|
avg_class = avg_label_to_class(avg_text) |
|
|
conclusion = interpret_results(avg_class, dl_class) |
|
|
|
|
|
extra = f""" |
|
|
<div class="hint">ID FreeSound : <b>{sound_id}</b></div> |
|
|
<div style="margin-top:12px; padding-top:10px; border-top:1px dashed #d1d5db"> |
|
|
{conclusion} |
|
|
</div> |
|
|
""" |
|
|
return html_result(badge, duration, avg_text, dl_text, extra_html=extra) |
|
|
|
|
|
return html_error( |
|
|
"Durée non supportée", |
|
|
f"Durée détectée : <b>{duration:.2f} s</b><br><br>" |
|
|
f"Plages acceptées :<br>" |
|
|
f"• Effet sonore : <b>{MIN_EFFECT}–{MAX_EFFECT} s</b><br>" |
|
|
f"• Musique : <b>{MIN_MUSIC}–{MAX_MUSIC} s</b>" |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
theme = gr.themes.Soft() |
|
|
|
|
|
with gr.Blocks(title="Démo — Popularité Audio", css=CSS) as demo: |
|
|
gr.HTML( |
|
|
f""" |
|
|
<div id="header-title">Démo — Prédiction de popularité audio</div> |
|
|
<p id="header-sub"> |
|
|
Deux modes : <b>Upload audio</b> (openSMILE) ou <b>URL FreeSound</b> (features API).<br><br> |
|
|
<b>Durées acceptées :</b> 🔊 Effet sonore {MIN_EFFECT}–{MAX_EFFECT}s · 🎵 Musique {MIN_MUSIC}–{MAX_MUSIC}s |
|
|
</p> |
|
|
""" |
|
|
) |
|
|
|
|
|
with gr.Tabs(): |
|
|
with gr.Tab("1) Upload audio (openSMILE)"): |
|
|
with gr.Row(): |
|
|
with gr.Column(scale=1): |
|
|
gr.Markdown("### Importer un fichier") |
|
|
audio_in = gr.Audio(type="filepath", label="Fichier audio") |
|
|
btn_audio = gr.Button("🚀 Prédire (upload)", variant="primary") |
|
|
with gr.Column(scale=1): |
|
|
gr.Markdown("### Résultat") |
|
|
out_audio = gr.HTML() |
|
|
btn_audio.click(predict_from_uploaded_audio, inputs=audio_in, outputs=out_audio) |
|
|
|
|
|
with gr.Tab("2) URL FreeSound (features API)"): |
|
|
with gr.Row(): |
|
|
with gr.Column(scale=1): |
|
|
gr.Markdown("### Coller une URL FreeSound") |
|
|
url_in = gr.Textbox(label="URL FreeSound", placeholder="https://freesound.org/s/123456/") |
|
|
btn_url = gr.Button("🚀 Prédire (URL FreeSound)", variant="primary") |
|
|
with gr.Column(scale=1): |
|
|
gr.Markdown("### Résultat") |
|
|
out_url = gr.HTML() |
|
|
btn_url.click(predict_from_freesound_url, inputs=url_in, outputs=out_url) |
|
|
|
|
|
demo.launch(theme=theme) |
|
|
|