From b295c8c022633e18ffbf6656de2c5a43becaad9c Mon Sep 17 00:00:00 2001 From: scayac Date: Tue, 7 Apr 2026 21:52:31 +0200 Subject: [PATCH] feat: add fade in/out and normalize audio options to triggers; implement audio storage page - Updated TriggerConfig and TriggerPatch models to include fade_in_seconds, fade_out_seconds, and normalize_audio fields. - Enhanced the save_trigger route to handle new fields and validate input. - Added a new audio storage page with upload, delete, and playback functionalities. - Implemented UI changes in dashboard and audio storage templates to support new features. - Introduced modals for audio playback and deletion confirmation. --- .gitignore | 3 +- arduino/README_ARDUINO.md | 5 + backend/README_BACKEND.md | 20 +- backend/app/audio_player.py | 144 ++++++- backend/app/main.py | 48 ++- backend/app/models.py | 6 + frontend/README_FRONTEND.md | 5 + .../app/__pycache__/routes.cpython-312.pyc | Bin 18806 -> 21940 bytes frontend/app/routes.py | 103 ++++- frontend/app/templates/audio_storage.html | 237 +++++++++++ frontend/app/templates/base.html | 13 + frontend/app/templates/dashboard.html | 386 +++++++++++++++--- 12 files changed, 868 insertions(+), 102 deletions(-) create mode 100644 frontend/app/templates/audio_storage.html 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 58ce088ec92395064450c523c9e492eab148e494..c1e478bd6018cab436ca2128b52f266a3ecac979 100644 GIT binary patch delta 7545 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 K0XQ1kUHc#Y$a|3h 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 %} -
    - {% for filename in audio_files %} -
  • - {{ filename }} -
    - - Télécharger -
    - - -
    -
    -
  • - {% endfor %} -
- {% else %} -

Aucun fichier audio dans le stockage.

- {% endif %} - + + + +