diff --git a/.gitignore b/.gitignore index 7f73587..d77fdf8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ __pycache__ *.pyc certs -conf.json \ No newline at end of file +conf.json +backend/data/musiques/* \ No newline at end of file diff --git a/arduino/README_ARDUINO.md b/arduino/README_ARDUINO.md index 42b8c5b..a525832 100644 --- a/arduino/README_ARDUINO.md +++ b/arduino/README_ARDUINO.md @@ -75,3 +75,8 @@ pio device monitor -b 115200 -p /dev/ttyUSB0 4. Demarrer le backend. 5. Appuyer sur le bouton. 6. Verifier les logs backend: reception `Serial trigger received: GPIOX`. + +## Changelog + +- Le changelog global du projet est dans `../CHANGELOG.md`. +- Les regles de mise a jour sont decrites dans `../docs/CHANGELOG_GUIDE.md`. diff --git a/backend/README_BACKEND.md b/backend/README_BACKEND.md index 63b365c..2c5188b 100644 --- a/backend/README_BACKEND.md +++ b/backend/README_BACKEND.md @@ -27,10 +27,18 @@ backend/ ## Prerequis - Python 3.11+ -- `ffplay` installe (paquet ffmpeg) +- `sox` installe (commandes `play` et `soxi`) +- `libsox-fmt-mp3` installe (support MP3 pour sox) - Un serveur son installé et configuré (exemple: `alsa` sur debian avec alsa-utils et configuration de la carte son) - acces au port serie (exemple: `/dev/ttyUSB0`) +Installation audio recommandee (Debian/Ubuntu): + +```bash +sudo apt update +sudo apt install sox libsox-fmt-mp3 ffmpeg +``` + ## Installation ```bash @@ -68,7 +76,10 @@ Exemple d'entree trigger: "music_file": "bell.mp3", "start_seconds": 2.5, "end_seconds": 10.0, - "volume": 0.8 + "volume": 80, + "fade_in_seconds": 0.3, + "fade_out_seconds": 0.4, + "normalize_audio": true } ``` @@ -134,3 +145,8 @@ sudo systemctl enable --now pysonnerie-backend ``` Adapte les chemins `WorkingDirectory` et `ExecStart` avant activation. + +## Changelog + +- Le changelog global du projet est dans `../CHANGELOG.md`. +- Les regles de mise a jour sont decrites dans `../docs/CHANGELOG_GUIDE.md`. diff --git a/backend/app/audio_player.py b/backend/app/audio_player.py index d015bf7..7ebac82 100644 --- a/backend/app/audio_player.py +++ b/backend/app/audio_player.py @@ -1,16 +1,21 @@ from __future__ import annotations +import logging import subprocess import threading from pathlib import Path from typing import Optional +logger = logging.getLogger("pysonnerie.audio") + + class AudioPlayer: def __init__(self, music_dir: Path): self.music_dir = music_dir self._lock = threading.RLock() self._proc: Optional[subprocess.Popen] = None + self._decoder_proc: Optional[subprocess.Popen] = None def _resolve_music_path(self, music_file: str) -> Path: candidate = (self.music_dir / music_file).resolve() @@ -20,32 +25,139 @@ class AudioPlayer: raise FileNotFoundError(f"Music file not found: {music_file}") return candidate - def play(self, music_file: str, start_seconds: float = 0.0, end_seconds: Optional[float] = None, volume: int = 80) -> None: + def _read_duration_seconds(self, music_path: Path) -> Optional[float]: + try: + result = subprocess.run( + ["soxi", "-D", str(music_path)], + capture_output=True, + check=True, + text=True, + ) + return float(result.stdout.strip()) + except (subprocess.CalledProcessError, FileNotFoundError, ValueError): + logger.warning("Unable to read duration with soxi for %s", music_path) + + # Fallback for setups where sox lacks some codecs (e.g. mp3). + try: + result = subprocess.run( + [ + "ffprobe", + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + str(music_path), + ], + capture_output=True, + check=True, + text=True, + ) + return float(result.stdout.strip()) + except (subprocess.CalledProcessError, FileNotFoundError, ValueError): + logger.warning("Unable to read duration with ffprobe for %s", music_path) + return None + + def play( + self, + music_file: str, + start_seconds: float = 0.0, + end_seconds: Optional[float] = None, + volume: int = 80, + fade_in_seconds: float = 0.0, + fade_out_seconds: float = 0.0, + normalize_audio: bool = False, + ) -> None: with self._lock: music_path = self._resolve_music_path(music_file) self.stop() - cmd = [ - "ffplay", - "-nodisp", - "-autoexit", - "-loglevel", - "error", - "-volume", - str(max(0, min(100, volume))), - "-ss", - str(start_seconds), - ] + if start_seconds < 0: + raise ValueError("start_seconds must be greater than or equal to 0") + + duration: Optional[float] = None if end_seconds is not None: - duration = end_seconds - start_seconds + duration = float(end_seconds) - float(start_seconds) if duration <= 0: raise ValueError("end_seconds must be greater than start_seconds") - cmd += ["-t", str(duration)] - cmd.append(str(music_path)) - self._proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + volume_factor = max(0.0, min(1.0, float(volume) / 100.0)) + cmd = [ + "play", + "-q", + "-v", + str(volume_factor), + str(music_path), + ] + + effects: list[str] = [] + if normalize_audio: + effects += ["gain", "-n"] + + effects += ["trim", str(float(start_seconds))] + if duration is not None: + effects.append(str(duration)) + + fade_in = max(0.0, float(fade_in_seconds)) + fade_out = max(0.0, float(fade_out_seconds)) + if fade_in > 0 or fade_out > 0: + play_duration = duration + if play_duration is None: + total_duration = self._read_duration_seconds(music_path) + if total_duration is not None: + play_duration = total_duration - float(start_seconds) + + if play_duration is not None: + if play_duration <= 0: + raise ValueError("Playback window is empty") + + fade_in = min(fade_in, play_duration) + fade_out = min(fade_out, play_duration) + + if fade_out > 0: + effects += ["fade", "t", str(fade_in), str(play_duration), str(fade_out)] + else: + effects += ["fade", "t", str(fade_in)] + else: + if fade_out > 0: + logger.warning( + "fade_out_seconds ignored for %s because duration is unknown", + music_path, + ) + effects += ["fade", "t", str(fade_in)] + + # If sox lacks mp3 handlers, decode with ffmpeg and stream wav to sox. + if music_path.suffix.lower() == ".mp3": + decoder_cmd = ["ffmpeg", "-v", "error", "-i", str(music_path), "-f", "wav", "-"] + self._decoder_proc = subprocess.Popen( + decoder_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + cmd += ["-t", "wav", "-"] + effects + self._proc = subprocess.Popen( + cmd, + stdin=self._decoder_proc.stdout, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if self._decoder_proc.stdout is not None: + self._decoder_proc.stdout.close() + else: + cmd += [str(music_path)] + effects + self._proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) def stop(self) -> None: with self._lock: + if self._decoder_proc and self._decoder_proc.poll() is None: + self._decoder_proc.terminate() + try: + self._decoder_proc.wait(timeout=2) + except subprocess.TimeoutExpired: + self._decoder_proc.kill() + self._decoder_proc = None + if self._proc and self._proc.poll() is None: self._proc.terminate() try: diff --git a/backend/app/main.py b/backend/app/main.py index 1ca3846..bb621eb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -65,10 +65,32 @@ def _play_from_trigger(trigger_id: str) -> Dict[str, object]: start_seconds=float(trigger.get("start_seconds", 0.0) or 0.0), end_seconds=trigger.get("end_seconds"), volume=int(trigger.get("volume", 80)), + fade_in_seconds=float(trigger.get("fade_in_seconds", 0.0) or 0.0), + fade_out_seconds=float(trigger.get("fade_out_seconds", 0.0) or 0.0), + normalize_audio=bool(trigger.get("normalize_audio", False)), ) return trigger +def _normalize_text(value: object) -> str: + return str(value or "").strip().lower() + + +def _ensure_unique_trigger_identity( + triggers: Dict[str, object], + current_id: str, + candidate_type: str, + candidate_name: str, +) -> None: + for existing_id, existing_trigger in triggers.items(): + if existing_id == current_id or not isinstance(existing_trigger, dict): + continue + if existing_trigger.get("type") == candidate_type: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Trigger type already used") + if _normalize_text(existing_trigger.get("name")) == _normalize_text(candidate_name): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Trigger name already used") + + @app.on_event("startup") def startup_event() -> None: global serial_listener @@ -101,6 +123,9 @@ def _serial_callback(raw_message: str) -> TriggerLogInfo | None: start_seconds=float(trigger.get("start_seconds", 0.0) or 0.0), end_seconds=trigger.get("end_seconds"), volume=int(trigger.get("volume", 80)), + fade_in_seconds=float(trigger.get("fade_in_seconds", 0.0) or 0.0), + fade_out_seconds=float(trigger.get("fade_out_seconds", 0.0) or 0.0), + normalize_audio=bool(trigger.get("normalize_audio", False)), ) info: TriggerLogInfo = {"key": trigger_key} @@ -131,11 +156,11 @@ def list_triggers() -> Dict[str, object]: @app.put("/api/triggers/{trigger_id}", dependencies=[Depends(auth_required)]) def upsert_trigger(trigger_id: str, payload: TriggerConfig) -> Dict[str, object]: - if payload.type != trigger_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="trigger_id must match payload.type", - ) + config = config_store.get_copy() + triggers = config.get("triggers", {}) + if not isinstance(triggers, dict): + triggers = {} + _ensure_unique_trigger_identity(triggers, trigger_id, payload.type, payload.name) return config_store.upsert_trigger(trigger_id, payload.model_dump()) @@ -152,6 +177,17 @@ def patch_trigger(trigger_id: str, payload: TriggerPatch) -> Dict[str, object]: candidate = {**existing, **updates} TriggerConfig.model_validate(candidate) + config = config_store.get_copy() + triggers = config.get("triggers", {}) + if not isinstance(triggers, dict): + triggers = {} + _ensure_unique_trigger_identity( + triggers, + trigger_id, + str(candidate.get("type", "")), + str(candidate.get("name", "")), + ) + try: return config_store.patch_trigger(trigger_id, updates) except KeyError: @@ -173,6 +209,8 @@ def force_play(trigger_id: str) -> Dict[str, str]: _play_from_trigger(trigger_id) except KeyError: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown trigger") + except (FileNotFoundError, ValueError, RuntimeError) as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) return {"status": "playing"} diff --git a/backend/app/models.py b/backend/app/models.py index 4114d00..5f6767f 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -12,6 +12,9 @@ class TriggerConfig(BaseModel): start_seconds: Optional[float] = Field(default=0.0, ge=0) end_seconds: Optional[float] = Field(default=None, ge=0) volume: int = Field(default=80, ge=0, le=100) + fade_in_seconds: float = Field(default=0.0, ge=0) + fade_out_seconds: float = Field(default=0.0, ge=0) + normalize_audio: bool = Field(default=False) @field_validator("end_seconds") @classmethod @@ -29,6 +32,9 @@ class TriggerPatch(BaseModel): start_seconds: Optional[float] = Field(default=None, ge=0) end_seconds: Optional[float] = Field(default=None, ge=0) volume: Optional[int] = Field(default=None, ge=0, le=100) + fade_in_seconds: Optional[float] = Field(default=None, ge=0) + fade_out_seconds: Optional[float] = Field(default=None, ge=0) + normalize_audio: Optional[bool] = Field(default=None) class ForcePlayRequest(BaseModel): diff --git a/frontend/README_FRONTEND.md b/frontend/README_FRONTEND.md index b7d30cc..f0e15ee 100644 --- a/frontend/README_FRONTEND.md +++ b/frontend/README_FRONTEND.md @@ -99,3 +99,8 @@ Le frontend sera alors servi par Gunicorn sur l'adresse definie par `FRONTEND_BI - Le frontend appelle le backend en HTTPS avec certificat autosigne (`verify=False`). - Les fichiers audio sont manipules localement dans `backend/data/musiques`. - Formats audio acceptes: `.mp3`, `.wav`, `.ogg`, `.flac`, `.aac`, `.m4a`. + +## Changelog + +- Le changelog global du projet est dans `../CHANGELOG.md`. +- Les regles de mise a jour sont decrites dans `../docs/CHANGELOG_GUIDE.md`. diff --git a/frontend/app/__pycache__/routes.cpython-312.pyc b/frontend/app/__pycache__/routes.cpython-312.pyc index 58ce088..c1e478b 100644 Binary files a/frontend/app/__pycache__/routes.cpython-312.pyc and b/frontend/app/__pycache__/routes.cpython-312.pyc differ diff --git a/frontend/app/routes.py b/frontend/app/routes.py index 9450f04..116cf78 100644 --- a/frontend/app/routes.py +++ b/frontend/app/routes.py @@ -65,6 +65,31 @@ def _parse_optional_float(value: str) -> float | None: return float(clean) +def _next_numeric_trigger_id(triggers: dict[str, dict]) -> str: + max_id = 0 + for key in triggers: + if key.isdigit(): + max_id = max(max_id, int(key)) + return str(max_id + 1) + + +def _normalize_key(value: str) -> str: + return value.strip().lower() + + +def _audio_redirect_target() -> str: + if request.form.get("return_to", "").strip() == "audio_storage": + return url_for("ui.audio_storage") + return url_for("ui.dashboard") + + +def _trigger_sort_key(item: tuple[str, dict]) -> tuple[int, int | str]: + trigger_id = item[0] + if trigger_id.isdigit(): + return (0, int(trigger_id)) + return (1, trigger_id.lower()) + + @ui.get("/") def home() -> Response: if _backend_client() is None: @@ -121,7 +146,8 @@ def dashboard() -> str | Response: triggers: dict[str, dict] = {} try: raw = client.list_triggers() - triggers = {k: v for k, v in raw.items() if isinstance(v, dict)} + valid_triggers = {k: v for k, v in raw.items() if isinstance(v, dict)} + triggers = dict(sorted(valid_triggers.items(), key=_trigger_sort_key)) except BackendApiError as exc: flash(f"Impossible de charger les triggers: {exc}", "error") @@ -136,6 +162,16 @@ def dashboard() -> str | Response: ) +@ui.get("/audio-storage") +def audio_storage() -> str | Response: + client_or_redirect = _ensure_login() + if isinstance(client_or_redirect, Response): + return client_or_redirect + + audio_files = _list_audio_files() + return render_template("audio_storage.html", audio_files=audio_files) + + @ui.post("/trigger/save") def save_trigger() -> Response: client_or_redirect = _ensure_login() @@ -152,6 +188,9 @@ def save_trigger() -> Response: start_raw = request.form.get("start_seconds", "0") end_raw = request.form.get("end_seconds", "") volume_raw = request.form.get("volume", "80") + fade_in_raw = request.form.get("fade_in_seconds", "0") + fade_out_raw = request.form.get("fade_out_seconds", "0") + normalize_audio = request.form.get("normalize_audio") in {"on", "true", "1"} def _err(msg: str) -> Response: if request.headers.get("X-Requested-With") == "fetch": @@ -169,8 +208,10 @@ def save_trigger() -> Response: start_seconds = float(start_raw) end_seconds = _parse_optional_float(end_raw) volume = int(volume_raw) + fade_in_seconds = float(fade_in_raw) + fade_out_seconds = float(fade_out_raw) except ValueError: - return _err("Les temps de debut/fin et le volume doivent être numériques.") + return _err("Les temps de debut/fin, fondus et le volume doivent être numériques.") if start_seconds < 0 or (end_seconds is not None and end_seconds <= start_seconds): return _err("Fenêtre temporelle invalide.") @@ -178,6 +219,9 @@ def save_trigger() -> Response: if not (0 <= volume <= 100): return _err("Le volume doit être compris entre 0 et 100.") + if fade_in_seconds < 0 or fade_out_seconds < 0: + return _err("Les durées de fondu doivent être supérieures ou égales à 0.") + payload = { "name": name, "type": trigger_type, @@ -185,14 +229,29 @@ def save_trigger() -> Response: "start_seconds": start_seconds, "end_seconds": end_seconds, "volume": volume, + "fade_in_seconds": fade_in_seconds, + "fade_out_seconds": fade_out_seconds, + "normalize_audio": normalize_audio, } try: - if original_id and original_id != trigger_type: - client.upsert_trigger(trigger_type, payload) - client.delete_trigger(original_id) + existing_raw = client.list_triggers() + existing_triggers = {k: v for k, v in existing_raw.items() if isinstance(v, dict)} + + if original_id and original_id in existing_triggers: + trigger_id = original_id else: - client.upsert_trigger(trigger_type, payload) + trigger_id = _next_numeric_trigger_id(existing_triggers) + + for existing_id, existing_trigger in existing_triggers.items(): + if existing_id == trigger_id: + continue + if existing_trigger.get("type") == trigger_type: + return _err("Ce type de trigger est déjà utilisé.") + if _normalize_key(str(existing_trigger.get("name", ""))) == _normalize_key(name): + return _err("Ce nom de trigger est déjà utilisé.") + + client.upsert_trigger(trigger_id, payload) except BackendApiError as exc: if request.headers.get("X-Requested-With") == "fetch": return jsonify({"ok": False, "error": str(exc)}), 502 @@ -202,12 +261,12 @@ def save_trigger() -> Response: if request.headers.get("X-Requested-With") == "fetch": return jsonify({ "ok": True, - "message": f"Trigger {trigger_type} enregistre.", - "trigger_id": trigger_type, - "original_id": original_id or trigger_type, + "message": f"Trigger \"{name}\" enregistré.", + "trigger_id": trigger_id, + "original_id": original_id or trigger_id, "trigger": payload, }) - flash(f"Trigger {trigger_type} enregistré.", "success") + flash(f"Trigger \"{name}\" enregistré.", "success") return redirect(url_for("ui.dashboard")) @@ -230,8 +289,8 @@ def delete_trigger() -> Response: try: client.delete_trigger(trigger_id) if request.headers.get("X-Requested-With") == "fetch": - return jsonify({"ok": True, "message": f"Trigger {trigger_id} supprime.", "trigger_id": trigger_id}) - flash(f"Trigger {trigger_id} supprime.", "success") + return jsonify({"ok": True, "message": "Trigger supprimé.", "trigger_id": trigger_id}) + flash("Trigger supprimé.", "success") except BackendApiError as exc: if request.headers.get("X-Requested-With") == "fetch": return jsonify({"ok": False, "error": f"Echec de suppression: {exc}"}), 500 @@ -258,8 +317,8 @@ def play_trigger() -> Response: try: client.play_trigger(trigger_id) if request.headers.get("X-Requested-With") == "fetch": - return jsonify({"ok": True, "message": f"Trigger {trigger_id} demarre."}) - flash(f"Trigger {trigger_id} demarré.", "success") + return jsonify({"ok": True, "message": "Trigger démarré."}) + flash("Trigger démarré.", "success") except BackendApiError as exc: if request.headers.get("X-Requested-With") == "fetch": return jsonify({"ok": False, "error": str(exc)}), 502 @@ -297,27 +356,27 @@ def upload_audio() -> Response: audio = request.files.get("audio_file") if audio is None or audio.filename is None or audio.filename.strip() == "": flash("Sélectionnez d'abord un fichier.", "error") - return redirect(url_for("ui.dashboard")) + return redirect(_audio_redirect_target()) filename = secure_filename(audio.filename) if filename == "": flash("Nom de fichier invalide.", "error") - return redirect(url_for("ui.dashboard")) + return redirect(_audio_redirect_target()) ext = Path(filename).suffix.lower() if ext not in ALLOWED_AUDIO_EXTENSIONS: flash("Format audio non supporté.", "error") - return redirect(url_for("ui.dashboard")) + return redirect(_audio_redirect_target()) existing_names = {item.name.lower() for item in _music_dir().iterdir() if item.is_file()} if filename.lower() in existing_names: flash("Un fichier avec ce nom existe déjà.", "error") - return redirect(url_for("ui.dashboard")) + return redirect(_audio_redirect_target()) destination = _music_dir() / filename audio.save(destination) flash(f"Fichier {filename} téléversé.", "success") - return redirect(url_for("ui.dashboard")) + return redirect(_audio_redirect_target()) @ui.post("/audio/delete") @@ -329,16 +388,16 @@ def delete_audio() -> Response: filename = request.form.get("filename", "").strip() if not filename: flash("Nom de fichier manquant.", "error") - return redirect(url_for("ui.dashboard")) + return redirect(_audio_redirect_target()) target = _music_dir() / filename if not target.exists() or not target.is_file(): flash("Fichier introuvable.", "error") - return redirect(url_for("ui.dashboard")) + return redirect(_audio_redirect_target()) target.unlink() flash(f"Fichier {filename} supprimé.", "success") - return redirect(url_for("ui.dashboard")) + return redirect(_audio_redirect_target()) @ui.get("/audio/download/") diff --git a/frontend/app/templates/audio_storage.html b/frontend/app/templates/audio_storage.html new file mode 100644 index 0000000..74289a5 --- /dev/null +++ b/frontend/app/templates/audio_storage.html @@ -0,0 +1,237 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Stockage audio

+
+ +
+ +
+
+
+ + + +
+ + {% if audio_files %} +
    + {% for filename in audio_files %} +
  • + {{ filename }} +
    + + Télécharger +
    + + + +
    +
    +
  • + {% endfor %} +
+ {% else %} +

Aucun fichier audio dans le stockage.

+ {% endif %} +
+
+ + + + + + + + +{% endblock %} diff --git a/frontend/app/templates/base.html b/frontend/app/templates/base.html index 1dd8b19..54c4da1 100644 --- a/frontend/app/templates/base.html +++ b/frontend/app/templates/base.html @@ -322,6 +322,19 @@ button.small { accent-color: var(--brand); } +.normalize-row { + display: flex; + align-items: center; + gap: 0.55rem; +} + +.normalize-row input[type="checkbox"] { + width: auto; + margin: 0; + padding: 0; + flex-shrink: 0; +} + .table-wrap { overflow-x: auto; } diff --git a/frontend/app/templates/dashboard.html b/frontend/app/templates/dashboard.html index 68634c7..394b0db 100644 --- a/frontend/app/templates/dashboard.html +++ b/frontend/app/templates/dashboard.html @@ -7,6 +7,7 @@
+ Stockage audio Déconnexion
@@ -15,10 +16,7 @@

Ajouter ou modifier un trigger

- + +
+ + +
+
@@ -73,13 +85,15 @@ > - ID Nom Type Audio Début Fin Volume + Fade in + Fade out + Normalisation Actions @@ -94,15 +108,20 @@ data-trigger-start="{{ trigger.get('start_seconds', 0) }}" data-trigger-end="{{ '' if trigger.get('end_seconds') is none else trigger.get('end_seconds') }}" data-trigger-volume="{{ trigger.get('volume', 80) }}" + data-trigger-fade-in="{{ trigger.get('fade_in_seconds', 0.0) }}" + data-trigger-fade-out="{{ trigger.get('fade_out_seconds', 0.0) }}" + data-trigger-normalize="{{ 1 if trigger.get('normalize_audio', false) else 0 }}" title="Cliquer pour charger ce trigger dans le formulaire" > - {{ trigger_id }} {{ trigger.get('name', '') }} {{ trigger.get('type', '') }} {{ trigger.get('music_file', '') }} {{ trigger.get('start_seconds', 0) }} {{ trigger.get('end_seconds', '') }} {{ trigger.get('volume', 80) }} + {{ trigger.get('fade_in_seconds', 0.0) }} + {{ trigger.get('fade_out_seconds', 0.0) }} + {{ 'Oui' if trigger.get('normalize_audio', false) else 'Non' }} - - - {% if audio_files %} - - {% else %} -

Aucun fichier audio dans le stockage.

- {% endif %} - + + + +