Browse Source

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.
master
scayac 4 weeks ago
parent
commit
b295c8c022
  1. 3
      .gitignore
  2. 5
      arduino/README_ARDUINO.md
  3. 20
      backend/README_BACKEND.md
  4. 144
      backend/app/audio_player.py
  5. 48
      backend/app/main.py
  6. 6
      backend/app/models.py
  7. 5
      frontend/README_FRONTEND.md
  8. BIN
      frontend/app/__pycache__/routes.cpython-312.pyc
  9. 103
      frontend/app/routes.py
  10. 237
      frontend/app/templates/audio_storage.html
  11. 13
      frontend/app/templates/base.html
  12. 386
      frontend/app/templates/dashboard.html

3
.gitignore vendored

@ -2,4 +2,5 @@ @@ -2,4 +2,5 @@
__pycache__
*.pyc
certs
conf.json
conf.json
backend/data/musiques/*

5
arduino/README_ARDUINO.md

@ -75,3 +75,8 @@ pio device monitor -b 115200 -p /dev/ttyUSB0 @@ -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`.

20
backend/README_BACKEND.md

@ -27,10 +27,18 @@ backend/ @@ -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: @@ -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 @@ -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`.

144
backend/app/audio_player.py

@ -1,16 +1,21 @@ @@ -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: @@ -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:

48
backend/app/main.py

@ -65,10 +65,32 @@ def _play_from_trigger(trigger_id: str) -> Dict[str, object]: @@ -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: @@ -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]: @@ -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]: @@ -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]: @@ -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"}

6
backend/app/models.py

@ -12,6 +12,9 @@ class TriggerConfig(BaseModel): @@ -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): @@ -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):

5
frontend/README_FRONTEND.md

@ -99,3 +99,8 @@ Le frontend sera alors servi par Gunicorn sur l'adresse definie par `FRONTEND_BI @@ -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`.

BIN
frontend/app/__pycache__/routes.cpython-312.pyc

Binary file not shown.

103
frontend/app/routes.py

@ -65,6 +65,31 @@ def _parse_optional_float(value: str) -> float | None: @@ -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: @@ -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: @@ -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: @@ -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: @@ -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: @@ -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: @@ -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: @@ -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: @@ -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: @@ -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 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: @@ -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: @@ -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/<path:filename>")

237
frontend/app/templates/audio_storage.html

@ -0,0 +1,237 @@ @@ -0,0 +1,237 @@
{% extends "base.html" %}
{% block content %}
<header class="topbar reveal">
<div>
<h1>Stockage audio</h1>
</div>
<div class="topbar-actions">
<a href="{{ url_for('ui.dashboard') }}" class="ghost-link">Retour au tableau de bord</a>
<a href="{{ url_for('ui.logout') }}" class="ghost-link">Déconnexion</a>
</div>
</header>
<main class="dashboard-grid">
<section class="panel reveal delay-1" style="grid-column: span 12;">
<form method="post" action="{{ url_for('ui.upload_audio') }}" enctype="multipart/form-data" class="upload-row" id="upload-audio-form">
<input type="hidden" name="return_to" value="audio_storage" />
<input type="file" name="audio_file" id="audio_file_input" accept=".mp3,.wav,.ogg,.flac,.aac,.m4a" required />
<button type="submit">Téléverser</button>
</form>
{% if audio_files %}
<ul class="audio-list">
{% for filename in audio_files %}
<li>
<span>{{ filename }}</span>
<div class="audio-actions">
<button
type="button"
class="small-button js-play-browser"
data-audio-url="{{ url_for('ui.stream_audio', filename=filename) }}"
data-audio-name="{{ filename }}"
>
Lire
</button>
<a href="{{ url_for('ui.download_audio', filename=filename) }}" class="small-button">Télécharger</a>
<form method="post" action="{{ url_for('ui.delete_audio') }}" class="inline-form js-delete-audio-form" data-filename="{{ filename }}">
<input type="hidden" name="filename" value="{{ filename }}" />
<input type="hidden" name="return_to" value="audio_storage" />
<button type="submit" class="small danger">Supprimer</button>
</form>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">Aucun fichier audio dans le stockage.</p>
{% endif %}
</section>
</main>
<div class="modal-backdrop" id="duplicate-name-modal" aria-hidden="true">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="duplicate-name-title">
<h3 id="duplicate-name-title">Nom déjà utilisé</h3>
<p id="duplicate-name-message">Ce nom existe déjà dans les fichiers enregistrés.</p>
<div class="modal-actions">
<button type="button" class="ghost-link" id="duplicate-name-close">Fermer</button>
</div>
</div>
</div>
<div class="modal-backdrop" id="delete-audio-modal" aria-hidden="true">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="delete-audio-title">
<h3 id="delete-audio-title">Confirmer la suppression</h3>
<p id="delete-audio-message">Voulez-vous vraiment supprimer ce fichier ?</p>
<div class="modal-actions">
<button type="button" class="ghost-link" id="delete-audio-cancel">Annuler</button>
<button type="button" class="danger" id="delete-audio-confirm">Supprimer</button>
</div>
</div>
</div>
<div class="modal-backdrop" id="browser-audio-modal" aria-hidden="true">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="browser-audio-title">
<h3 id="browser-audio-title">Lecture audio</h3>
<div class="audio-player-card">
<p id="audio-now-playing" class="muted">Aucune lecture en cours.</p>
<audio id="browser-audio-player" controls preload="metadata"></audio>
<p class="audio-time" id="audio-time">Temps de lecture: 00:00 / 00:00</p>
</div>
<div class="modal-actions">
<button type="button" class="ghost-link" id="browser-audio-close">Fermer</button>
</div>
</div>
</div>
<script>
(function () {
const player = document.getElementById("browser-audio-player");
const timeLabel = document.getElementById("audio-time");
const nowPlaying = document.getElementById("audio-now-playing");
const modal = document.getElementById("browser-audio-modal");
const closeBtn = document.getElementById("browser-audio-close");
if (!player || !timeLabel || !nowPlaying || !modal || !closeBtn) return;
const openModal = () => {
modal.classList.add("is-open");
modal.setAttribute("aria-hidden", "false");
};
const closeModal = () => {
player.pause();
modal.classList.remove("is-open");
modal.setAttribute("aria-hidden", "true");
};
const formatTime = (seconds) => {
if (!Number.isFinite(seconds) || seconds < 0) return "00:00";
const total = Math.floor(seconds);
const minutes = Math.floor(total / 60);
const remain = total % 60;
return String(minutes).padStart(2, "0") + ":" + String(remain).padStart(2, "0");
};
const updateTimeLabel = () => {
const current = formatTime(player.currentTime || 0);
const total = formatTime(player.duration || 0);
timeLabel.textContent = "Temps de lecture: " + current + " / " + total;
};
player.addEventListener("timeupdate", updateTimeLabel);
player.addEventListener("loadedmetadata", updateTimeLabel);
player.addEventListener("ended", updateTimeLabel);
const playButtons = document.querySelectorAll(".js-play-browser");
playButtons.forEach((button) => {
button.addEventListener("click", () => {
const src = button.dataset.audioUrl;
const name = button.dataset.audioName || "Fichier audio";
if (!src) return;
if (player.src !== src) {
player.src = src;
}
player.play().catch(() => {});
nowPlaying.textContent = "Lecture: " + name;
updateTimeLabel();
openModal();
});
});
closeBtn.addEventListener("click", closeModal);
modal.addEventListener("click", (event) => {
if (event.target === modal) closeModal();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && modal.classList.contains("is-open")) closeModal();
});
})();
(function () {
const uploadForm = document.getElementById("upload-audio-form");
const fileInput = document.getElementById("audio_file_input");
const duplicateModal = document.getElementById("duplicate-name-modal");
const duplicateClose = document.getElementById("duplicate-name-close");
if (!uploadForm || !fileInput || !duplicateModal || !duplicateClose) return;
const existing = new Set([
{% for filename in audio_files %}
"{{ filename|lower|replace('\\', '\\\\')|replace('"', '\\"') }}",
{% endfor %}
]);
const openDuplicateModal = () => {
duplicateModal.classList.add("is-open");
duplicateModal.setAttribute("aria-hidden", "false");
duplicateClose.focus();
};
const closeDuplicateModal = () => {
duplicateModal.classList.remove("is-open");
duplicateModal.setAttribute("aria-hidden", "true");
};
uploadForm.addEventListener("submit", (event) => {
const file = fileInput.files && fileInput.files[0];
if (!file) return;
if (existing.has(file.name.toLowerCase())) {
event.preventDefault();
openDuplicateModal();
}
});
duplicateClose.addEventListener("click", closeDuplicateModal);
duplicateModal.addEventListener("click", (event) => {
if (event.target === duplicateModal) closeDuplicateModal();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && duplicateModal.classList.contains("is-open")) closeDuplicateModal();
});
})();
(function () {
const modal = document.getElementById("delete-audio-modal");
const message = document.getElementById("delete-audio-message");
const cancelBtn = document.getElementById("delete-audio-cancel");
const confirmBtn = document.getElementById("delete-audio-confirm");
if (!modal || !message || !cancelBtn || !confirmBtn) return;
let pendingForm = null;
const openModal = (form, filename) => {
pendingForm = form;
message.textContent = "Voulez-vous vraiment supprimer le fichier \"" + filename + "\" ?";
modal.classList.add("is-open");
modal.setAttribute("aria-hidden", "false");
confirmBtn.focus();
};
const closeModal = () => {
modal.classList.remove("is-open");
modal.setAttribute("aria-hidden", "true");
pendingForm = null;
};
document.querySelectorAll(".js-delete-audio-form").forEach((form) => {
form.addEventListener("submit", (event) => {
event.preventDefault();
const filename = form.dataset.filename || "ce fichier";
openModal(form, filename);
});
});
cancelBtn.addEventListener("click", closeModal);
modal.addEventListener("click", (event) => {
if (event.target === modal) closeModal();
});
confirmBtn.addEventListener("click", () => {
if (pendingForm) pendingForm.submit();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && modal.classList.contains("is-open")) closeModal();
});
})();
</script>
{% endblock %}

13
frontend/app/templates/base.html

@ -322,6 +322,19 @@ button.small { @@ -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;
}

386
frontend/app/templates/dashboard.html

@ -7,6 +7,7 @@ @@ -7,6 +7,7 @@
</div>
<div class="topbar-actions">
<button type="button" class="danger" id="btn-stop-audio">Arrêter l'audio</button>
<a href="{{ url_for('ui.audio_storage') }}" class="ghost-link">Stockage audio</a>
<a href="{{ url_for('ui.logout') }}" class="ghost-link">Déconnexion</a>
</div>
</header>
@ -15,10 +16,7 @@ @@ -15,10 +16,7 @@
<section class="panel reveal delay-1">
<h2>Ajouter ou modifier un trigger</h2>
<form method="post" action="{{ url_for('ui.save_trigger') }}" class="form-grid" id="trigger-form">
<label>
Identifiant du trigger existant (optionnel)
<input type="text" name="original_id" id="original_id" placeholder="GPIO3" />
</label>
<input type="hidden" name="original_id" id="original_id" />
<label>
Type du trigger (obligatoire)
<input type="text" name="type" id="trigger_type" placeholder="GPIO3" required />
@ -58,6 +56,20 @@ @@ -58,6 +56,20 @@
<span>Volume : <span id="trigger_volume_display">80</span>%</span>
<input type="range" min="0" max="100" name="volume" id="trigger_volume" value="80" />
</label>
<div class="time-row">
<label>
Fade in (s)
<input type="number" step="0.1" min="0" name="fade_in_seconds" id="fade_in_seconds" value="0" />
</label>
<label>
Fade out (s)
<input type="number" step="0.1" min="0" name="fade_out_seconds" id="fade_out_seconds" value="0" />
</label>
</div>
<label class="normalize-row">
<input type="checkbox" name="normalize_audio" id="normalize_audio" />
Normaliser le niveau sonore
</label>
<button type="submit">Enregistrer le trigger</button>
</form>
</section>
@ -73,13 +85,15 @@ @@ -73,13 +85,15 @@
>
<thead>
<tr>
<th>ID</th>
<th>Nom</th>
<th>Type</th>
<th>Audio</th>
<th>Début</th>
<th>Fin</th>
<th>Volume</th>
<th>Fade in</th>
<th>Fade out</th>
<th>Normalisation</th>
<th>Actions</th>
</tr>
</thead>
@ -94,15 +108,20 @@ @@ -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"
>
<td>{{ trigger_id }}</td>
<td>{{ trigger.get('name', '') }}</td>
<td>{{ trigger.get('type', '') }}</td>
<td>{{ trigger.get('music_file', '') }}</td>
<td>{{ trigger.get('start_seconds', 0) }}</td>
<td>{{ trigger.get('end_seconds', '') }}</td>
<td>{{ trigger.get('volume', 80) }}</td>
<td>{{ trigger.get('fade_in_seconds', 0.0) }}</td>
<td>{{ trigger.get('fade_out_seconds', 0.0) }}</td>
<td>{{ 'Oui' if trigger.get('normalize_audio', false) else 'Non' }}</td>
<td>
<button
type="button"
@ -123,46 +142,6 @@ @@ -123,46 +142,6 @@
</div>
</section>
<section class="panel reveal delay-3">
<h2>Stockage audio</h2>
<div class="audio-player-card">
<p id="audio-now-playing" class="muted">Aucune lecture en cours.</p>
<audio id="browser-audio-player" controls preload="metadata"></audio>
<p class="audio-time" id="audio-time">Temps de lecture: 00:00 / 00:00</p>
</div>
<form method="post" action="{{ url_for('ui.upload_audio') }}" enctype="multipart/form-data" class="upload-row" id="upload-audio-form">
<input type="file" name="audio_file" id="audio_file_input" accept=".mp3,.wav,.ogg,.flac,.aac,.m4a" required />
<button type="submit">Téléverser</button>
</form>
{% if audio_files %}
<ul class="audio-list">
{% for filename in audio_files %}
<li>
<span>{{ filename }}</span>
<div class="audio-actions">
<button
type="button"
class="small-button js-play-browser"
data-audio-url="{{ url_for('ui.stream_audio', filename=filename) }}"
data-audio-name="{{ filename }}"
>
Lire
</button>
<a href="{{ url_for('ui.download_audio', filename=filename) }}" class="small-button">Télécharger</a>
<form method="post" action="{{ url_for('ui.delete_audio') }}" class="inline-form js-delete-audio-form" data-filename="{{ filename }}">
<input type="hidden" name="filename" value="{{ filename }}" />
<button type="submit" class="small danger">Supprimer</button>
</form>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">Aucun fichier audio dans le stockage.</p>
{% endif %}
</section>
</main>
<div class="modal-backdrop" id="delete-audio-modal" aria-hidden="true">
@ -187,6 +166,21 @@ @@ -187,6 +166,21 @@
</div>
</div>
<div class="modal-backdrop" id="launch-trigger-modal" aria-hidden="true">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="launch-trigger-title">
<h3 id="launch-trigger-title">Avertissement audio</h3>
<p id="launch-trigger-message">La musique va être jouée sur la sortie audio. Continuer ?</p>
<label class="normalize-row" for="launch-trigger-dont-show">
<input type="checkbox" id="launch-trigger-dont-show" />
Ne plus réafficher cet avertissement
</label>
<div class="modal-actions">
<button type="button" class="ghost-link" id="launch-trigger-cancel">Annuler</button>
<button type="button" id="launch-trigger-confirm">Lancer</button>
</div>
</div>
</div>
<div class="modal-backdrop" id="duplicate-name-modal" aria-hidden="true">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="duplicate-name-title">
<h3 id="duplicate-name-title">Nom déjà utilisé</h3>
@ -197,7 +191,23 @@ @@ -197,7 +191,23 @@
</div>
</div>
<div class="modal-backdrop" id="browser-audio-modal" aria-hidden="true">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="browser-audio-title">
<h3 id="browser-audio-title">Lecture audio</h3>
<div class="audio-player-card">
<p id="audio-now-playing" class="muted">Aucune lecture en cours.</p>
<audio id="browser-audio-player" controls preload="metadata"></audio>
<p class="audio-time" id="audio-time">Temps de lecture: 00:00 / 00:00</p>
</div>
<div class="modal-actions">
<button type="button" class="ghost-link" id="browser-audio-close">Fermer</button>
</div>
</div>
</div>
<script>
const SKIP_LAUNCH_WARNING_KEY = "pysonnerie_skip_launch_trigger_warning";
function showFlash(message, category) {
let stack = document.querySelector(".flash-stack");
if (!stack) {
@ -218,6 +228,71 @@ @@ -218,6 +228,71 @@
}, 3600);
}
function confirmTriggerLaunch(triggerName) {
const skipWarning = localStorage.getItem(SKIP_LAUNCH_WARNING_KEY) === "1";
if (skipWarning) {
return Promise.resolve(true);
}
const modal = document.getElementById("launch-trigger-modal");
const message = document.getElementById("launch-trigger-message");
const dontShow = document.getElementById("launch-trigger-dont-show");
const cancelBtn = document.getElementById("launch-trigger-cancel");
const confirmBtn = document.getElementById("launch-trigger-confirm");
if (!modal || !message || !dontShow || !cancelBtn || !confirmBtn) {
return Promise.resolve(true);
}
message.textContent = "La musique va être jouée sur la sortie audio" +
(triggerName ? " pour \"" + triggerName + "\"" : "") + ". Continuer ?";
dontShow.checked = false;
return new Promise((resolve) => {
let done = false;
const cleanup = () => {
modal.classList.remove("is-open");
modal.setAttribute("aria-hidden", "true");
cancelBtn.removeEventListener("click", onCancel);
confirmBtn.removeEventListener("click", onConfirm);
modal.removeEventListener("click", onBackdropClick);
document.removeEventListener("keydown", onKeydown);
};
const finish = (ok) => {
if (done) return;
done = true;
if (ok && dontShow.checked) {
localStorage.setItem(SKIP_LAUNCH_WARNING_KEY, "1");
}
cleanup();
resolve(ok);
};
const onCancel = () => finish(false);
const onConfirm = () => finish(true);
const onBackdropClick = (event) => {
if (event.target === modal) {
finish(false);
}
};
const onKeydown = (event) => {
if (event.key === "Escape" && modal.classList.contains("is-open")) {
finish(false);
}
};
cancelBtn.addEventListener("click", onCancel);
confirmBtn.addEventListener("click", onConfirm);
modal.addEventListener("click", onBackdropClick);
document.addEventListener("keydown", onKeydown);
modal.classList.add("is-open");
modal.setAttribute("aria-hidden", "false");
confirmBtn.focus();
});
}
function upsertTriggerRow(data) {
const tbody = document.getElementById("triggers-tbody");
const tableWrap = document.getElementById("triggers-table-wrap");
@ -238,6 +313,9 @@ @@ -238,6 +313,9 @@
}
const endVal = (t.end_seconds != null && t.end_seconds !== "") ? t.end_seconds : "";
const fadeInVal = t.fade_in_seconds != null ? t.fade_in_seconds : 0;
const fadeOutVal = t.fade_out_seconds != null ? t.fade_out_seconds : 0;
const normalizeVal = !!t.normalize_audio;
let row = tbody.querySelector(`tr[data-trigger-id="${CSS.escape(triggerId)}"]`);
if (!row) {
@ -258,6 +336,10 @@ @@ -258,6 +336,10 @@
set("start_seconds", row.dataset.triggerStart || "0");
set("end_seconds", row.dataset.triggerEnd || "");
set("trigger_volume", row.dataset.triggerVolume || "80");
set("fade_in_seconds", row.dataset.triggerFadeIn || "0");
set("fade_out_seconds", row.dataset.triggerFadeOut || "0");
const normalize = document.getElementById("normalize_audio");
if (normalize) normalize.checked = (row.dataset.triggerNormalize === "1");
document.getElementById("trigger_type")?.focus();
f.scrollIntoView({ behavior: "smooth", block: "start" });
});
@ -270,15 +352,20 @@ @@ -270,15 +352,20 @@
row.dataset.triggerStart = t.start_seconds != null ? t.start_seconds : "0";
row.dataset.triggerEnd = endVal;
row.dataset.triggerVolume = t.volume != null ? t.volume : 80;
row.dataset.triggerFadeIn = fadeInVal;
row.dataset.triggerFadeOut = fadeOutVal;
row.dataset.triggerNormalize = normalizeVal ? "1" : "0";
row.innerHTML = `
<td>${triggerId}</td>
<td>${t.name || ""}</td>
<td>${t.type || ""}</td>
<td>${t.music_file || ""}</td>
<td>${t.start_seconds != null ? t.start_seconds : 0}</td>
<td>${endVal}</td>
<td>${t.volume != null ? t.volume : 80}</td>
<td>${fadeInVal}</td>
<td>${fadeOutVal}</td>
<td>${normalizeVal ? "Oui" : "Non"}</td>
<td>
<button type="button" class="small js-play-trigger"
data-trigger-id="${triggerId}"
@ -290,10 +377,15 @@ @@ -290,10 +377,15 @@
// Rebrancher le listener du bouton Lancer
const playBtn = row.querySelector(".js-play-trigger");
if (playBtn) {
playBtn.addEventListener("click", () => {
playBtn.addEventListener("click", async () => {
const tid = playBtn.dataset.triggerId;
const url = playBtn.dataset.playUrl;
if (!tid || !url) return;
const rowName = row.dataset.triggerName || "";
const ok = await confirmTriggerLaunch(rowName);
if (!ok) return;
playBtn.disabled = true;
const body = new URLSearchParams({ trigger_id: tid });
fetch(url, {
@ -342,11 +434,16 @@ @@ -342,11 +434,16 @@
(function () {
document.querySelectorAll(".js-play-trigger").forEach((btn) => {
btn.addEventListener("click", () => {
btn.addEventListener("click", async () => {
const triggerId = btn.dataset.triggerId;
const url = btn.dataset.playUrl;
if (!triggerId || !url) return;
const row = btn.closest("tr");
const triggerName = row?.dataset.triggerName || "";
const ok = await confirmTriggerLaunch(triggerName);
if (!ok) return;
btn.disabled = true;
const body = new URLSearchParams({ trigger_id: triggerId });
fetch(url, {
@ -384,6 +481,9 @@ @@ -384,6 +481,9 @@
const endInput = document.getElementById("end_seconds");
const volumeInput = document.getElementById("trigger_volume");
const volumeDisplay = document.getElementById("trigger_volume_display");
const fadeInInput = document.getElementById("fade_in_seconds");
const fadeOutInput = document.getElementById("fade_out_seconds");
const normalizeInput = document.getElementById("normalize_audio");
if (volumeInput && volumeDisplay) {
volumeInput.addEventListener("input", () => { volumeDisplay.textContent = volumeInput.value; });
}
@ -433,6 +533,9 @@ @@ -433,6 +533,9 @@
volumeInput.value = row.dataset.triggerVolume || "80";
if (volumeDisplay) volumeDisplay.textContent = volumeInput.value;
}
if (fadeInInput) fadeInInput.value = row.dataset.triggerFadeIn || "0";
if (fadeOutInput) fadeOutInput.value = row.dataset.triggerFadeOut || "0";
if (normalizeInput) normalizeInput.checked = (row.dataset.triggerNormalize === "1");
typeInput?.focus();
form.scrollIntoView({ behavior: "smooth", block: "start" });
@ -444,7 +547,20 @@ @@ -444,7 +547,20 @@
const player = document.getElementById("browser-audio-player");
const timeLabel = document.getElementById("audio-time");
const nowPlaying = document.getElementById("audio-now-playing");
if (!player || !timeLabel || !nowPlaying) return;
const modal = document.getElementById("browser-audio-modal");
const closeBtn = document.getElementById("browser-audio-close");
if (!player || !timeLabel || !nowPlaying || !modal || !closeBtn) return;
const openModal = () => {
modal.classList.add("is-open");
modal.setAttribute("aria-hidden", "false");
};
const closeModal = () => {
player.pause();
modal.classList.remove("is-open");
modal.setAttribute("aria-hidden", "true");
};
const formatTime = (seconds) => {
if (!Number.isFinite(seconds) || seconds < 0) return "00:00";
@ -478,8 +594,21 @@ @@ -478,8 +594,21 @@
});
nowPlaying.textContent = "Lecture: " + name;
updateTimeLabel();
openModal();
});
});
closeBtn.addEventListener("click", closeModal);
modal.addEventListener("click", (event) => {
if (event.target === modal) {
closeModal();
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && modal.classList.contains("is-open")) {
closeModal();
}
});
})();
(function () {
@ -593,10 +722,13 @@ @@ -593,10 +722,13 @@
if (!modal || !message || !cancelBtn || !confirmBtn || !tbody) return;
let pendingTriggerId = null;
let pendingTriggerName = "ce trigger";
const openModal = (triggerId) => {
pendingTriggerId = triggerId;
message.textContent = `Voulez-vous vraiment supprimer le trigger "${triggerId}" ?`;
const row = tbody.querySelector(`tr[data-trigger-id="${CSS.escape(triggerId)}"]`);
pendingTriggerName = row?.dataset.triggerName || "ce trigger";
message.textContent = `Voulez-vous vraiment supprimer le trigger "${pendingTriggerName}" ?`;
modal.classList.add("is-open");
modal.setAttribute("aria-hidden", "false");
confirmBtn.focus();
@ -606,6 +738,7 @@ @@ -606,6 +738,7 @@
modal.classList.remove("is-open");
modal.setAttribute("aria-hidden", "true");
pendingTriggerId = null;
pendingTriggerName = "ce trigger";
};
tbody.addEventListener("click", (event) => {
@ -660,18 +793,159 @@ @@ -660,18 +793,159 @@
const btn = document.getElementById("btn-preview-trigger-audio");
if (!select || !btn) return;
let previewContext = null;
let previewSource = null;
const stopPreview = () => {
if (previewSource) {
try {
previewSource.stop();
} catch (_) {}
previewSource = null;
}
};
const asNonNegative = (input, fallback) => {
const value = Number.parseFloat(input?.value ?? "");
if (!Number.isFinite(value) || value < 0) return fallback;
return value;
};
const computeNormalizeGain = (audioBuffer, startOffset, playDuration) => {
const sampleRate = audioBuffer.sampleRate;
const startFrame = Math.max(0, Math.floor(startOffset * sampleRate));
const endFrame = Math.min(audioBuffer.length, Math.ceil((startOffset + playDuration) * sampleRate));
if (endFrame <= startFrame) return 1;
let peak = 0;
for (let c = 0; c < audioBuffer.numberOfChannels; c += 1) {
const channel = audioBuffer.getChannelData(c);
for (let i = startFrame; i < endFrame; i += 1) {
const abs = Math.abs(channel[i]);
if (abs > peak) peak = abs;
}
}
if (peak <= 0) return 1;
return Math.min(1, 0.95 / peak);
};
btn.addEventListener("click", () => {
const filename = select.value;
if (!filename) return;
const streamBase = select.dataset.streamUrl || "";
const url = streamBase.replace("__FILE__", encodeURIComponent(filename));
const player = document.getElementById("browser-audio-player");
const nowPlaying = document.getElementById("audio-now-playing");
if (!player) return;
player.src = url;
player.play().catch(() => {});
if (nowPlaying) nowPlaying.textContent = "Lecture: " + filename;
player.closest("section")?.scrollIntoView({ behavior: "smooth", block: "nearest" });
const startInput = document.getElementById("start_seconds");
const endInput = document.getElementById("end_seconds");
const volumeInput = document.getElementById("trigger_volume");
const fadeInInput = document.getElementById("fade_in_seconds");
const fadeOutInput = document.getElementById("fade_out_seconds");
const normalizeInput = document.getElementById("normalize_audio");
const startSeconds = asNonNegative(startInput, 0);
const endSecondsRaw = Number.parseFloat(endInput?.value ?? "");
const hasEnd = Number.isFinite(endSecondsRaw) && endSecondsRaw > 0;
const volume = asNonNegative(volumeInput, 80);
const fadeInSeconds = asNonNegative(fadeInInput, 0);
const fadeOutSeconds = asNonNegative(fadeOutInput, 0);
const normalizeAudio = !!normalizeInput?.checked;
btn.disabled = true;
if (player) {
player.pause();
player.removeAttribute("src");
player.load();
}
stopPreview();
if (!previewContext) {
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) {
showFlash("Prévisualisation avancée non supportée par ce navigateur.", "error");
btn.disabled = false;
return;
}
previewContext = new Ctx();
}
if (previewContext.state === "suspended") {
previewContext.resume().catch(() => {});
}
fetch(url)
.then((res) => {
if (!res.ok) throw new Error("Téléchargement audio impossible");
return res.arrayBuffer();
})
.then((arr) => previewContext.decodeAudioData(arr))
.then((audioBuffer) => {
const duration = audioBuffer.duration;
if (!(startSeconds < duration)) {
throw new Error("Le début dépasse la durée du fichier.");
}
const requestedEnd = hasEnd ? endSecondsRaw : duration;
const clampedEnd = Math.min(duration, requestedEnd);
if (!(clampedEnd > startSeconds)) {
throw new Error("La fin doit être supérieure au début.");
}
const playDuration = clampedEnd - startSeconds;
const source = previewContext.createBufferSource();
source.buffer = audioBuffer;
const gainNode = previewContext.createGain();
source.connect(gainNode);
gainNode.connect(previewContext.destination);
const normalizeGain = normalizeAudio ? computeNormalizeGain(audioBuffer, startSeconds, playDuration) : 1;
const baseGain = Math.max(0, Math.min(1, volume / 100)) * normalizeGain;
const now = previewContext.currentTime;
const startAt = now + 0.03;
const endAt = startAt + playDuration;
const fadeIn = Math.min(Math.max(0, fadeInSeconds), playDuration);
const fadeOut = Math.min(Math.max(0, fadeOutSeconds), playDuration);
const fadeOutStart = Math.max(startAt + fadeIn, endAt - fadeOut);
gainNode.gain.cancelScheduledValues(now);
gainNode.gain.setValueAtTime(0, now);
if (fadeIn > 0) {
gainNode.gain.linearRampToValueAtTime(baseGain, startAt + fadeIn);
} else {
gainNode.gain.setValueAtTime(baseGain, startAt);
}
if (fadeOut > 0) {
gainNode.gain.setValueAtTime(baseGain, fadeOutStart);
gainNode.gain.linearRampToValueAtTime(0.0001, endAt);
} else {
gainNode.gain.setValueAtTime(baseGain, endAt);
}
source.onended = () => {
if (previewSource === source) {
previewSource = null;
}
};
previewSource = source;
source.start(startAt, startSeconds, playDuration);
if (nowPlaying) {
nowPlaying.textContent = "Prévisualisation: " + filename +
" (" + startSeconds.toFixed(1) + "s -> " + clampedEnd.toFixed(1) + "s)";
}
player?.closest("section")?.scrollIntoView({ behavior: "smooth", block: "nearest" });
})
.catch((err) => {
showFlash(err?.message || "Prévisualisation impossible.", "error");
})
.finally(() => {
btn.disabled = false;
});
});
})();
</script>

Loading…
Cancel
Save