| import threading |
| import subprocess |
| import sys |
| import os |
| import time |
| import csv |
| from fastapi import Request |
| from fastapi.responses import JSONResponse, FileResponse, StreamingResponse, Response |
| from fastapi.staticfiles import StaticFiles |
| from reachy_mini import ReachyMini, ReachyMiniApp |
| from reachy_mini.utils import create_head_pose |
| import numpy as np |
| import soundfile as sf |
| import scipy.signal |
| import yt_dlp |
|
|
| |
|
|
| DOSSIER = os.path.dirname(os.path.abspath(__file__)) |
| STATIC = os.path.join(DOSSIER, "static") |
| DOSSIER_CSV = os.path.join(DOSSIER, "liste", "csv") |
| DOSSIER_LANDMARKS = os.path.join(DOSSIER, "liste", "landmarks") |
| DOSSIER_AUDIO = os.path.join(DOSSIER, "liste", "audio") |
|
|
| |
| os.makedirs(DOSSIER_CSV, exist_ok=True) |
| os.makedirs(DOSSIER_LANDMARKS, exist_ok=True) |
| os.makedirs(DOSSIER_AUDIO, exist_ok=True) |
|
|
| |
|
|
| class Coquille(ReachyMiniApp): |
| custom_app_url: str | None = "http://localhost:8042" |
| request_media_backend: str | None = None |
|
|
| def __init__(self, *args, **kwargs): |
| super().__init__(*args, **kwargs) |
| self.robot: ReachyMini | None = None |
|
|
| |
| self.robot_ready = threading.Event() |
| self.playback_ready = threading.Event() |
| self.playback_paused = False |
| self.playback_stop = False |
|
|
| def run(self, reachy_mini: ReachyMini, stop_event: threading.Event): |
| self.robot = reachy_mini |
| self.robot_ready.set() |
| print("[ROBOT] Connecté et prêt") |
| stop_event.wait() |
|
|
|
|
| |
|
|
| def interruptible_sleep(seconds: float, app_instance: Coquille): |
| deadline = time.perf_counter() + seconds |
| while time.perf_counter() < deadline: |
| if app_instance.playback_stop: |
| return |
| if app_instance.playback_paused: |
| deadline += 0.01 |
| time.sleep(0.01) |
|
|
|
|
| |
| def execute_jalon3_thread(csv_path: str, wav_path: str, app_instance: Coquille): |
|
|
| |
| if not app_instance.robot_ready.wait(timeout=15): |
| print("[JALON3] Timeout : robot non connecté") |
| app_instance.playback_ready.set() |
| return |
|
|
| robot = app_instance.robot |
| if robot is None: |
| print("[JALON3] Robot None — abandon") |
| app_instance.playback_ready.set() |
| return |
|
|
| try: |
|
|
| |
| with open(csv_path, newline='') as f: |
| rows = list(csv.DictReader(f)) |
|
|
| |
| audio_ok = False |
| if os.path.exists(wav_path): |
| try: |
| |
| audio, samplerate_in = sf.read(wav_path, dtype="float32") |
|
|
| |
| if audio.ndim > 1: |
| audio = np.mean(audio, axis=1) |
|
|
|
|
| output_sr = robot.media.get_output_audio_samplerate() |
| if samplerate_in != output_sr: |
| audio = scipy.signal.resample( |
| audio, |
| int(len(audio) * (output_sr / samplerate_in)) |
| ) |
| audio_ok = True |
| print(f"[JALON3] Audio chargé ({len(audio)} samples)") |
| except Exception as e: |
| print(f"[JALON3] Erreur chargement audio : {e}") |
| else: |
| print(f"[JALON3] Pas de fichier WAV : {wav_path}") |
|
|
| print(f"[JALON3] {len(rows)} frames — signal prêt envoyé") |
|
|
| |
| app_instance.playback_ready.set() |
|
|
| |
| if audio_ok: |
| try: |
| robot.media.start_playing() |
| robot.media.push_audio_sample(audio) |
| print("[JALON3] Audio lancé") |
| except Exception as e: |
| print(f"[JALON3] Erreur démarrage audio : {e}") |
|
|
| |
| start_time = time.perf_counter() |
|
|
| for idx, row in enumerate(rows): |
|
|
| |
| if app_instance.playback_stop: |
| print(f"[JALON3] Stop à frame {idx}") |
| break |
|
|
| |
| while app_instance.playback_paused and not app_instance.playback_stop: |
| time.sleep(0.01) |
|
|
| if app_instance.playback_stop: |
| break |
|
|
| try: |
| |
| |
| |
| |
| |
| |
| |
| head_pose = create_head_pose( |
| x=float(row["bouche"]), |
| y=float(row["norm_x"]), |
| z=float(row["norm_y"]), |
| pitch=float(row["pitch"]), |
| yaw=float(row["yaw"]), |
| roll=float(row["roll"]), |
| ) |
|
|
| |
| |
| antennas_rad = np.deg2rad(np.array([float(row["oeil_g"]), float(row["oeil_d"])])) |
|
|
| |
| robot.set_target(head=head_pose, antennas=antennas_rad) |
|
|
| |
| |
| ts = float(row["timestamp"]) |
| elapsed = time.perf_counter() - start_time |
| sleep_time = ts - elapsed |
| if sleep_time > 0.001: |
| interruptible_sleep(sleep_time, app_instance) |
|
|
| except Exception as e: |
| print(f"[JALON3] Erreur frame {idx}: {e}") |
|
|
| |
| if audio_ok: |
| try: |
| robot.media.stop_playing() |
| except Exception as e: |
| print(f"[JALON3] Erreur arrêt audio : {e}") |
|
|
| print("[JALON3] Terminé") |
|
|
| except Exception as e: |
| print(f"[JALON3] Erreur générale: {e}") |
| app_instance.playback_ready.set() |
|
|
|
|
| |
| app = Coquille() |
|
|
| |
| app.settings_app.mount("/static", StaticFiles(directory=STATIC), name="static") |
|
|
|
|
| @app.settings_app.get("/") |
| def index(): |
| |
| return FileResponse(os.path.join(STATIC, "index.html")) |
|
|
|
|
| @app.settings_app.get("/favicon.ico") |
| def favicon(): |
| |
| return Response(status_code=204) |
|
|
|
|
| @app.settings_app.get("/liste") |
| def liste(): |
| |
| noms = [f[:-4] for f in os.listdir(DOSSIER_CSV) if f.endswith(".csv")] |
| return JSONResponse({"noms": noms}) |
|
|
|
|
| @app.settings_app.post("/charger") |
| async def charger(request: Request): |
| |
| |
| |
| form = await request.form() |
| video_file = form.get("video") |
| nom = (form.get("nom") or "").strip() |
|
|
| if not video_file or not nom: |
| return JSONResponse({"succes": False, "message": "Vidéo ou nom manquant."}, status_code=400) |
|
|
| |
| video_path = os.path.join(DOSSIER, "video_temp.mp4") |
| with open(video_path, "wb") as f: |
| f.write(await video_file.read()) |
|
|
| try: |
| subprocess.run( |
| [sys.executable, os.path.join(DOSSIER, "Jalon2OPTI.py"), video_path, nom], |
| cwd=DOSSIER, capture_output=True, text=True, check=True, timeout=300 |
| ) |
| print(f"[PIPELINE] OK pour {nom}") |
| except subprocess.CalledProcessError: |
| return JSONResponse({"succes": False, "message": "Pipeline échoué"}, status_code=500) |
| except subprocess.TimeoutExpired: |
| return JSONResponse({"succes": False, "message": "Pipeline timeout"}, status_code=500) |
|
|
| return JSONResponse({"succes": True, "nom": nom}) |
|
|
|
|
| @app.settings_app.post("/supprimer") |
| async def supprimer(request: Request): |
| |
| |
| data = await request.json() |
| nom = data.get("nom") |
| if not nom: |
| return JSONResponse({"succes": False, "message": "Nom manquant"}, status_code=400) |
|
|
| |
| for path in [ |
| os.path.join(DOSSIER_CSV, f"{nom}.csv"), |
| os.path.join(DOSSIER_LANDMARKS, f"{nom}(landmarks).mp4"), |
| os.path.join(DOSSIER_AUDIO, f"{nom}.wav"), |
| ]: |
| if os.path.exists(path): |
| os.remove(path) |
|
|
| print(f"[DELETE] {nom} supprimé") |
| return JSONResponse({"succes": True}) |
|
|
|
|
| @app.settings_app.post("/play") |
| async def play(request: Request): |
| |
| |
| |
| |
| |
|
|
| |
| data = await request.json() |
| nom = data.get("nom", "").strip() |
|
|
| if not nom: |
| return JSONResponse({"succes": False, "message": "Nom manquant"}, status_code=400) |
|
|
| csv_path = os.path.join(DOSSIER_CSV, f"{nom}.csv") |
| wav_path = os.path.join(DOSSIER_AUDIO, f"{nom}.wav") |
|
|
| if not os.path.exists(csv_path): |
| return JSONResponse({"succes": False, "message": f"CSV introuvable : {nom}"}, status_code=404) |
|
|
| |
| app.playback_paused = False |
| app.playback_stop = False |
| app.playback_ready.clear() |
|
|
| thread = threading.Thread(target=execute_jalon3_thread, args=(csv_path, wav_path, app), daemon=True) |
| thread.start() |
|
|
| print(f"[PLAY] Thread lancé pour {nom}") |
| return JSONResponse({"succes": True}) |
|
|
|
|
| @app.settings_app.get("/is-ready") |
| async def is_ready(): |
| |
| |
| return JSONResponse({"ready": app.playback_ready.is_set()}) |
|
|
|
|
| @app.settings_app.post("/pause") |
| async def pause(request: Request): |
| |
| app.playback_paused = True |
| return JSONResponse({"succes": True}) |
|
|
|
|
| @app.settings_app.post("/resume") |
| async def resume(request: Request): |
| |
| app.playback_paused = False |
| return JSONResponse({"succes": True}) |
|
|
|
|
| @app.settings_app.post("/stop") |
| async def stop(request: Request): |
| |
| app.playback_stop = True |
| return JSONResponse({"succes": True}) |
|
|
|
|
| @app.settings_app.get("/video/{nom}") |
| def video(nom: str, request: Request): |
| |
| path = os.path.join(DOSSIER_LANDMARKS, f"{nom}(landmarks).mp4") |
| if not os.path.exists(path): |
| return JSONResponse({"succes": False, "message": f"Vidéo introuvable : {nom}"}, status_code=404) |
|
|
| file_size = os.path.getsize(path) |
| range_header = request.headers.get("range") |
|
|
| if range_header: |
| start, _, end_str = range_header.replace("bytes=", "").partition("-") |
| start = int(start) |
| end = int(end_str) if end_str else file_size - 1 |
| end = min(end, file_size - 1) |
| chunk = end - start + 1 |
|
|
| def stream(): |
| with open(path, "rb") as f: |
| f.seek(start) |
| rem = chunk |
| while rem > 0: |
| data = f.read(min(65536, rem)) |
| if not data: |
| break |
| rem -= len(data) |
| yield data |
|
|
| return StreamingResponse(stream(), status_code=206, media_type="video/mp4", headers={ |
| "Content-Range": f"bytes {start}-{end}/{file_size}", |
| "Accept-Ranges": "bytes", |
| "Content-Length": str(chunk), |
| }) |
|
|
| return FileResponse(path, media_type="video/mp4", headers={"Accept-Ranges": "bytes"}) |
|
|
|
|
| @app.settings_app.post("/telecharger") |
| async def telecharger(request: Request): |
| |
| |
| data = await request.json() |
| url = data.get("url", "").strip() |
| if not url: |
| return JSONResponse({"succes": False, "message": "URL manquante."}, status_code=400) |
|
|
| video_path = os.path.join(DOSSIER, "video_temp.mp4") |
| if os.path.exists(video_path): |
| os.remove(video_path) |
| try: |
| ydl_opts = { |
| "outtmpl": video_path, |
| |
| "format": "mp4/bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]", |
| "merge_output_format": "mp4", |
| } |
| with yt_dlp.YoutubeDL(ydl_opts) as ydl: |
| ydl.download([url]) |
| except Exception as e: |
| return JSONResponse({"succes": False, "message": str(e)}, status_code=500) |
|
|
| return JSONResponse({"succes": True}) |
|
|
|
|
| @app.settings_app.post("/lancer-youtube") |
| async def lancer_youtube(request: Request): |
| |
| |
| data = await request.json() |
| nom = (data.get("nom") or "").strip() |
| if not nom: |
| return JSONResponse({"succes": False, "message": "Nom manquant."}, status_code=400) |
|
|
| video_path = os.path.join(DOSSIER, "video_temp.mp4") |
| if not os.path.exists(video_path): |
| return JSONResponse({"succes": False, "message": "Aucune vidéo téléchargée."}, status_code=404) |
|
|
| try: |
| subprocess.run( |
| [sys.executable, os.path.join(DOSSIER, "Jalon2OPTI.py"), video_path, nom], |
| cwd=DOSSIER, capture_output=True, text=True, |
| encoding="utf-8", errors="replace", check=True |
| ) |
| print(f"[PIPELINE] OK pour {nom}") |
| except subprocess.CalledProcessError as e: |
| return JSONResponse({ |
| "succes": False, |
| "message": e.stderr or e.stdout or "erreur inconnue" |
| }, status_code=500) |
|
|
| return JSONResponse({"succes": True, "nom": nom}) |
|
|
|
|
| if __name__ == "__main__": |
| import uvicorn |
|
|
| |
| threading.Thread(target=app.wrapped_run, daemon=True).start() |
|
|
| |
| uvicorn.run(app.settings_app, host="127.0.0.1", port=8042) |