diff --git a/backend/README_BACKEND.md b/backend/README_BACKEND.md index 2c5188b..1d452ed 100644 --- a/backend/README_BACKEND.md +++ b/backend/README_BACKEND.md @@ -79,10 +79,15 @@ Exemple d'entree trigger: "volume": 80, "fade_in_seconds": 0.3, "fade_out_seconds": 0.4, + "repeat_count": 0, "normalize_audio": true } ``` +Champ supplementaire: + +- `repeat_count` (entier >= 0) : nombre de repetitions automatiques apres la premiere lecture (`0` = pas de repetition) + ## Lancement ```bash @@ -121,7 +126,8 @@ curl -k -u admin:change-me \ "type": "GPIO23", "music_file": "bell.mp3", "start_seconds": 0, - "end_seconds": null + "end_seconds": null, + "repeat_count": 0 }' ``` diff --git a/backend/app/audio_player.py b/backend/app/audio_player.py index 7ebac82..46df0d8 100644 --- a/backend/app/audio_player.py +++ b/backend/app/audio_player.py @@ -67,6 +67,7 @@ class AudioPlayer: volume: int = 80, fade_in_seconds: float = 0.0, fade_out_seconds: float = 0.0, + repeat_count: int = 0, normalize_audio: bool = False, ) -> None: with self._lock: @@ -75,6 +76,8 @@ class AudioPlayer: if start_seconds < 0: raise ValueError("start_seconds must be greater than or equal to 0") + if repeat_count < 0: + raise ValueError("repeat_count must be greater than or equal to 0") duration: Optional[float] = None if end_seconds is not None: @@ -127,6 +130,9 @@ class AudioPlayer: ) effects += ["fade", "t", str(fade_in)] + if repeat_count > 0: + effects += ["repeat", str(int(repeat_count))] + # 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", "-"] diff --git a/backend/app/main.py b/backend/app/main.py index bb621eb..1ec3ce8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -57,9 +57,15 @@ def _resolve_trigger(config: Dict[str, object], trigger_id: str) -> tuple[str, D raise KeyError(trigger_id) -def _play_from_trigger(trigger_id: str) -> Dict[str, object]: +def _play_from_trigger(trigger_id: str, repeat_count_override: int | None = None) -> Dict[str, object]: config = config_store.get_copy() _resolved_key, trigger = _resolve_trigger(config, trigger_id) + effective_repeat_count = int(trigger.get("repeat_count", 0) or 0) + if repeat_count_override is not None: + effective_repeat_count = int(repeat_count_override) + if effective_repeat_count < 0: + raise ValueError("repeat_count must be greater than or equal to 0") + audio_player.play( music_file=trigger["music_file"], start_seconds=float(trigger.get("start_seconds", 0.0) or 0.0), @@ -67,6 +73,7 @@ def _play_from_trigger(trigger_id: str) -> Dict[str, object]: 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), + repeat_count=effective_repeat_count, normalize_audio=bool(trigger.get("normalize_audio", False)), ) return trigger @@ -125,6 +132,7 @@ def _serial_callback(raw_message: str) -> TriggerLogInfo | None: 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), + repeat_count=int(trigger.get("repeat_count", 0) or 0), normalize_audio=bool(trigger.get("normalize_audio", False)), ) @@ -204,9 +212,9 @@ def delete_trigger(trigger_id: str) -> Dict[str, str]: @app.get("/api/play/{trigger_id}", dependencies=[Depends(auth_required)]) -def force_play(trigger_id: str) -> Dict[str, str]: +def force_play(trigger_id: str, repeat_count: int | None = None) -> Dict[str, str]: try: - _play_from_trigger(trigger_id) + _play_from_trigger(trigger_id, repeat_count_override=repeat_count) except KeyError: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown trigger") except (FileNotFoundError, ValueError, RuntimeError) as exc: diff --git a/backend/app/models.py b/backend/app/models.py index 5f6767f..3d7b3e7 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -14,6 +14,7 @@ class TriggerConfig(BaseModel): 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) + repeat_count: int = Field(default=0, ge=0) normalize_audio: bool = Field(default=False) @field_validator("end_seconds") @@ -34,6 +35,7 @@ class TriggerPatch(BaseModel): 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) + repeat_count: Optional[int] = Field(default=None, ge=0) normalize_audio: Optional[bool] = Field(default=None) diff --git a/frontend/README_FRONTEND.md b/frontend/README_FRONTEND.md index 01cfa38..f7356f6 100644 --- a/frontend/README_FRONTEND.md +++ b/frontend/README_FRONTEND.md @@ -6,11 +6,23 @@ Frontend web responsive en Flask pour piloter le backend pySonnerie déjà en pl - Page de connexion (URL backend + identifiants Basic Auth) - Tableau de bord de gestion des triggers (creation/modification/suppression) +- Parametrage du nombre de repetitions automatiques par trigger (champ `Nombre de repetitions`) - Lancement manuel d'un trigger (`/api/play/{trigger_id}`) - Arrêt audio (`/api/stop`) - Gestion du stockage audio dans `backend/data/musiques` (televersement, telechargement, suppression) - Import audio depuis un lien YouTube (extraction via `yt-dlp` puis conversion en MP3) +## Parametres trigger (dashboard) + +Le formulaire trigger permet de regler: + +- Fenetre de lecture (`Debut`, `Fin`) +- Volume et fondus (`Fade in`, `Fade out`) +- Normalisation audio +- `Nombre de repetitions` : +- `0` = lecture unique +- `N` (> 0) = `N` repetitions automatiques apres la premiere lecture + ## Prerequis - Python 3.11+ diff --git a/frontend/app/backend_client.py b/frontend/app/backend_client.py index 2b5e426..8436993 100644 --- a/frontend/app/backend_client.py +++ b/frontend/app/backend_client.py @@ -97,8 +97,11 @@ class BackendClient: data = self._request("DELETE", f"/api/triggers/{trigger_id}") return data if isinstance(data, dict) else {} - def play_trigger(self, trigger_id: str) -> dict[str, Any]: - data = self._request("GET", f"/api/play/{trigger_id}") + def play_trigger(self, trigger_id: str, repeat_count: int | None = None) -> dict[str, Any]: + path = f"/api/play/{trigger_id}" + if repeat_count is not None: + path += f"?repeat_count={repeat_count}" + data = self._request("GET", path) return data if isinstance(data, dict) else {} def stop_audio(self) -> dict[str, Any]: diff --git a/frontend/app/routes.py b/frontend/app/routes.py index 0323e6e..cd3b520 100644 --- a/frontend/app/routes.py +++ b/frontend/app/routes.py @@ -393,6 +393,7 @@ def save_trigger() -> Response: 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") + repeat_count_raw = request.form.get("repeat_count", "0") normalize_audio = request.form.get("normalize_audio") in {"on", "true", "1"} def _err(msg: str) -> Response: @@ -413,8 +414,9 @@ def save_trigger() -> Response: volume = int(volume_raw) fade_in_seconds = float(fade_in_raw) fade_out_seconds = float(fade_out_raw) + repeat_count = int(repeat_count_raw) except ValueError: - return _err("Les temps de debut/fin, fondus et le volume doivent être numériques.") + return _err("Les temps de debut/fin, fondus, repetitions 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.") @@ -425,6 +427,9 @@ def save_trigger() -> Response: if fade_in_seconds < 0 or fade_out_seconds < 0: return _err("Les durées de fondu doivent être supérieures ou égales à 0.") + if repeat_count < 0: + return _err("Le nombre de répétitions doit être supérieur ou égal à 0.") + payload = { "name": name, "type": trigger_type, @@ -434,6 +439,7 @@ def save_trigger() -> Response: "volume": volume, "fade_in_seconds": fade_in_seconds, "fade_out_seconds": fade_out_seconds, + "repeat_count": repeat_count, "normalize_audio": normalize_audio, } @@ -511,14 +517,31 @@ def play_trigger() -> Response: client = client_or_redirect trigger_id = request.form.get("trigger_id", "").strip() + repeat_count_raw = request.form.get("repeat_count", "").strip() if not trigger_id: if request.headers.get("X-Requested-With") == "fetch": return jsonify({"ok": False, "error": "Identifiant du trigger manquant."}), 400 flash("Identifiant du trigger manquant.", "error") return redirect(url_for("ui.dashboard")) + repeat_count: int | None = None + if repeat_count_raw: + try: + parsed_repeat_count = int(repeat_count_raw) + except ValueError: + if request.headers.get("X-Requested-With") == "fetch": + return jsonify({"ok": False, "error": "Nombre de répétitions invalide."}), 400 + flash("Nombre de répétitions invalide.", "error") + return redirect(url_for("ui.dashboard")) + if parsed_repeat_count < 0: + if request.headers.get("X-Requested-With") == "fetch": + return jsonify({"ok": False, "error": "Le nombre de répétitions doit être >= 0."}), 400 + flash("Le nombre de répétitions doit être >= 0.", "error") + return redirect(url_for("ui.dashboard")) + repeat_count = parsed_repeat_count + try: - client.play_trigger(trigger_id) + client.play_trigger(trigger_id, repeat_count=repeat_count) if request.headers.get("X-Requested-With") == "fetch": return jsonify({"ok": True, "message": "Trigger démarré."}) flash("Trigger démarré.", "success") diff --git a/frontend/app/templates/dashboard.html b/frontend/app/templates/dashboard.html index 394b0db..06a13c9 100644 --- a/frontend/app/templates/dashboard.html +++ b/frontend/app/templates/dashboard.html @@ -66,6 +66,10 @@ + + Nombre de répétitions + + Normaliser le niveau sonore @@ -93,6 +97,7 @@ Volume Fade in Fade out + Répétitions Normalisation Actions @@ -110,6 +115,7 @@ 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-repeat-count="{{ trigger.get('repeat_count', 0) }}" data-trigger-normalize="{{ 1 if trigger.get('normalize_audio', false) else 0 }}" title="Cliquer pour charger ce trigger dans le formulaire" > @@ -121,12 +127,14 @@ {{ trigger.get('volume', 80) }} {{ trigger.get('fade_in_seconds', 0.0) }} {{ trigger.get('fade_out_seconds', 0.0) }} + {{ trigger.get('repeat_count', 0) }} {{ 'Oui' if trigger.get('normalize_audio', false) else 'Non' }} Lancer ${t.volume != null ? t.volume : 80} ${fadeInVal} ${fadeOutVal} + ${repeatCountVal} ${normalizeVal ? "Oui" : "Non"} Lancer Supprimer @@ -387,7 +400,9 @@ if (!ok) return; playBtn.disabled = true; - const body = new URLSearchParams({ trigger_id: tid }); + const parsedRepeatCount = Number.parseInt(playBtn.dataset.repeatCount || row.dataset.triggerRepeatCount || "0", 10); + const safeRepeatCount = Number.isFinite(parsedRepeatCount) && parsedRepeatCount >= 0 ? String(parsedRepeatCount) : "0"; + const body = new URLSearchParams({ trigger_id: tid, repeat_count: safeRepeatCount }); fetch(url, { method: "POST", headers: { "X-Requested-With": "fetch", "Content-Type": "application/x-www-form-urlencoded" }, @@ -445,7 +460,9 @@ if (!ok) return; btn.disabled = true; - const body = new URLSearchParams({ trigger_id: triggerId }); + const parsedRepeatCount = Number.parseInt(btn.dataset.repeatCount || row?.dataset.triggerRepeatCount || "0", 10); + const safeRepeatCount = Number.isFinite(parsedRepeatCount) && parsedRepeatCount >= 0 ? String(parsedRepeatCount) : "0"; + const body = new URLSearchParams({ trigger_id: triggerId, repeat_count: safeRepeatCount }); fetch(url, { method: "POST", headers: { "X-Requested-With": "fetch", "Content-Type": "application/x-www-form-urlencoded" }, @@ -483,6 +500,7 @@ const volumeDisplay = document.getElementById("trigger_volume_display"); const fadeInInput = document.getElementById("fade_in_seconds"); const fadeOutInput = document.getElementById("fade_out_seconds"); + const repeatCountInput = document.getElementById("repeat_count"); const normalizeInput = document.getElementById("normalize_audio"); if (volumeInput && volumeDisplay) { volumeInput.addEventListener("input", () => { volumeDisplay.textContent = volumeInput.value; }); @@ -535,6 +553,7 @@ } if (fadeInInput) fadeInInput.value = row.dataset.triggerFadeIn || "0"; if (fadeOutInput) fadeOutInput.value = row.dataset.triggerFadeOut || "0"; + if (repeatCountInput) repeatCountInput.value = row.dataset.triggerRepeatCount || "0"; if (normalizeInput) normalizeInput.checked = (row.dataset.triggerNormalize === "1"); typeInput?.focus(); @@ -551,6 +570,21 @@ const closeBtn = document.getElementById("browser-audio-close"); if (!player || !timeLabel || !nowPlaying || !modal || !closeBtn) return; + let previewName = "Fichier audio"; + let repeatsRemaining = 0; + let totalPlays = 1; + let currentPlay = 1; + + const parseRepeatCount = (rawValue) => { + const parsed = Number.parseInt(String(rawValue ?? ""), 10); + if (!Number.isFinite(parsed) || parsed < 0) return 0; + return parsed; + }; + + const updateNowPlaying = () => { + nowPlaying.textContent = "Lecture: " + previewName + " (" + currentPlay + "/" + totalPlays + ")"; + }; + const openModal = () => { modal.classList.add("is-open"); modal.setAttribute("aria-hidden", "false"); @@ -558,6 +592,9 @@ const closeModal = () => { player.pause(); + repeatsRemaining = 0; + totalPlays = 1; + currentPlay = 1; modal.classList.remove("is-open"); modal.setAttribute("aria-hidden", "true"); }; @@ -578,7 +615,18 @@ player.addEventListener("timeupdate", updateTimeLabel); player.addEventListener("loadedmetadata", updateTimeLabel); - player.addEventListener("ended", updateTimeLabel); + player.addEventListener("ended", () => { + if (repeatsRemaining <= 0) { + updateTimeLabel(); + return; + } + repeatsRemaining -= 1; + currentPlay += 1; + player.currentTime = 0; + player.play().catch(() => {}); + updateNowPlaying(); + updateTimeLabel(); + }); const playButtons = document.querySelectorAll(".js-play-browser"); playButtons.forEach((button) => { button.addEventListener("click", () => { @@ -586,13 +634,25 @@ const name = button.dataset.audioName || "Fichier audio"; if (!src) return; + const triggerRepeatInput = document.getElementById("repeat_count"); + const repeatCount = button.dataset.repeatCount != null + ? parseRepeatCount(button.dataset.repeatCount) + : parseRepeatCount(triggerRepeatInput?.value); + + previewName = name; + repeatsRemaining = repeatCount; + totalPlays = repeatCount + 1; + currentPlay = 1; + if (player.src !== src) { player.src = src; + } else { + player.currentTime = 0; } player.play().catch(() => { // Browsers may block autoplay in some contexts; user can press play manually. }); - nowPlaying.textContent = "Lecture: " + name; + updateNowPlaying(); updateTimeLabel(); openModal(); }); @@ -795,8 +855,10 @@ let previewContext = null; let previewSource = null; + let previewSessionId = 0; const stopPreview = () => { + previewSessionId += 1; if (previewSource) { try { previewSource.stop(); @@ -842,6 +904,7 @@ const volumeInput = document.getElementById("trigger_volume"); const fadeInInput = document.getElementById("fade_in_seconds"); const fadeOutInput = document.getElementById("fade_out_seconds"); + const repeatCountInput = document.getElementById("repeat_count"); const normalizeInput = document.getElementById("normalize_audio"); const startSeconds = asNonNegative(startInput, 0); @@ -850,6 +913,7 @@ const volume = asNonNegative(volumeInput, 80); const fadeInSeconds = asNonNegative(fadeInInput, 0); const fadeOutSeconds = asNonNegative(fadeOutInput, 0); + const repeatCount = Math.max(0, Math.floor(asNonNegative(repeatCountInput, 0))); const normalizeAudio = !!normalizeInput?.checked; btn.disabled = true; @@ -894,50 +958,62 @@ } 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); - } + const currentSession = previewSessionId; + const totalPlays = repeatCount + 1; + + const startPreviewPlay = (playIndex) => { + if (currentSession !== previewSessionId) return; + + const source = previewContext.createBufferSource(); + source.buffer = audioBuffer; + const gainNode = previewContext.createGain(); + source.connect(gainNode); + gainNode.connect(previewContext.destination); + + const now = previewContext.currentTime; + const startAt = now + 0.03; + const endAt = startAt + 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; + source.onended = () => { + if (previewSource === source) { + previewSource = null; + } + if (currentSession !== previewSessionId) return; + if (playIndex < totalPlays) { + startPreviewPlay(playIndex + 1); + } + }; + + previewSource = source; + source.start(startAt, startSeconds, playDuration); + + if (nowPlaying) { + nowPlaying.textContent = "Prévisualisation: " + filename + + " (" + startSeconds.toFixed(1) + "s -> " + clampedEnd.toFixed(1) + "s)" + + " [" + playIndex + "/" + totalPlays + "]"; } }; - previewSource = source; - source.start(startAt, startSeconds, playDuration); - - if (nowPlaying) { - nowPlaying.textContent = "Prévisualisation: " + filename + - " (" + startSeconds.toFixed(1) + "s -> " + clampedEnd.toFixed(1) + "s)"; - } + startPreviewPlay(1); player?.closest("section")?.scrollIntoView({ behavior: "smooth", block: "nearest" }); }) .catch((err) => {