Browse Source

Gestion du nombre de répétitions des triggers

master
scayac 3 weeks ago
parent
commit
3f43d229da
  1. 8
      backend/README_BACKEND.md
  2. 6
      backend/app/audio_player.py
  3. 14
      backend/app/main.py
  4. 2
      backend/app/models.py
  5. 12
      frontend/README_FRONTEND.md
  6. 7
      frontend/app/backend_client.py
  7. 27
      frontend/app/routes.py
  8. 98
      frontend/app/templates/dashboard.html

8
backend/README_BACKEND.md

@ -79,10 +79,15 @@ Exemple d'entree trigger: @@ -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 \ @@ -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
}'
```

6
backend/app/audio_player.py

@ -67,6 +67,7 @@ class AudioPlayer: @@ -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: @@ -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: @@ -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", "-"]

14
backend/app/main.py

@ -57,9 +57,15 @@ def _resolve_trigger(config: Dict[str, object], trigger_id: str) -> tuple[str, D @@ -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]: @@ -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: @@ -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]: @@ -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:

2
backend/app/models.py

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

12
frontend/README_FRONTEND.md

@ -6,11 +6,23 @@ Frontend web responsive en Flask pour piloter le backend pySonnerie déjà en pl @@ -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+

7
frontend/app/backend_client.py

@ -97,8 +97,11 @@ class BackendClient: @@ -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]:

27
frontend/app/routes.py

@ -393,6 +393,7 @@ def save_trigger() -> Response: @@ -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: @@ -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: @@ -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: @@ -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: @@ -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")

98
frontend/app/templates/dashboard.html

@ -66,6 +66,10 @@ @@ -66,6 +66,10 @@
<input type="number" step="0.1" min="0" name="fade_out_seconds" id="fade_out_seconds" value="0" />
</label>
</div>
<label>
Nombre de répétitions
<input type="number" step="1" min="0" name="repeat_count" id="repeat_count" value="0" />
</label>
<label class="normalize-row">
<input type="checkbox" name="normalize_audio" id="normalize_audio" />
Normaliser le niveau sonore
@ -93,6 +97,7 @@ @@ -93,6 +97,7 @@
<th>Volume</th>
<th>Fade in</th>
<th>Fade out</th>
<th>Répétitions</th>
<th>Normalisation</th>
<th>Actions</th>
</tr>
@ -110,6 +115,7 @@ @@ -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 @@ @@ -121,12 +127,14 @@
<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>{{ trigger.get('repeat_count', 0) }}</td>
<td>{{ 'Oui' if trigger.get('normalize_audio', false) else 'Non' }}</td>
<td>
<button
type="button"
class="small js-play-trigger"
data-trigger-id="{{ trigger_id }}"
data-repeat-count="{{ trigger.get('repeat_count', 0) }}"
data-play-url="{{ url_for('ui.play_trigger') }}"
>Lancer</button>
<button
@ -315,6 +323,7 @@ @@ -315,6 +323,7 @@
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 repeatCountVal = t.repeat_count != null ? t.repeat_count : 0;
const normalizeVal = !!t.normalize_audio;
let row = tbody.querySelector(`tr[data-trigger-id="${CSS.escape(triggerId)}"]`);
@ -338,6 +347,7 @@ @@ -338,6 +347,7 @@
set("trigger_volume", row.dataset.triggerVolume || "80");
set("fade_in_seconds", row.dataset.triggerFadeIn || "0");
set("fade_out_seconds", row.dataset.triggerFadeOut || "0");
set("repeat_count", row.dataset.triggerRepeatCount || "0");
const normalize = document.getElementById("normalize_audio");
if (normalize) normalize.checked = (row.dataset.triggerNormalize === "1");
document.getElementById("trigger_type")?.focus();
@ -354,6 +364,7 @@ @@ -354,6 +364,7 @@
row.dataset.triggerVolume = t.volume != null ? t.volume : 80;
row.dataset.triggerFadeIn = fadeInVal;
row.dataset.triggerFadeOut = fadeOutVal;
row.dataset.triggerRepeatCount = repeatCountVal;
row.dataset.triggerNormalize = normalizeVal ? "1" : "0";
row.innerHTML = `
@ -365,10 +376,12 @@ @@ -365,10 +376,12 @@
<td>${t.volume != null ? t.volume : 80}</td>
<td>${fadeInVal}</td>
<td>${fadeOutVal}</td>
<td>${repeatCountVal}</td>
<td>${normalizeVal ? "Oui" : "Non"}</td>
<td>
<button type="button" class="small js-play-trigger"
data-trigger-id="${triggerId}"
data-repeat-count="${repeatCountVal}"
data-play-url="${playUrl}">Lancer</button>
<button type="button" class="small danger js-delete-trigger-btn"
data-trigger-id="${triggerId}">Supprimer</button>
@ -387,7 +400,9 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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,21 +958,25 @@ @@ -894,21 +958,25 @@
}
const playDuration = clampedEnd - startSeconds;
const normalizeGain = normalizeAudio ? computeNormalizeGain(audioBuffer, startSeconds, playDuration) : 1;
const baseGain = Math.max(0, Math.min(1, volume / 100)) * normalizeGain;
const fadeIn = Math.min(Math.max(0, fadeInSeconds), playDuration);
const fadeOut = Math.min(Math.max(0, fadeOutSeconds), playDuration);
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 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);
@ -929,6 +997,10 @@ @@ -929,6 +997,10 @@
if (previewSource === source) {
previewSource = null;
}
if (currentSession !== previewSessionId) return;
if (playIndex < totalPlays) {
startPreviewPlay(playIndex + 1);
}
};
previewSource = source;
@ -936,8 +1008,12 @@ @@ -936,8 +1008,12 @@
if (nowPlaying) {
nowPlaying.textContent = "Prévisualisation: " + filename +
" (" + startSeconds.toFixed(1) + "s -> " + clampedEnd.toFixed(1) + "s)";
" (" + startSeconds.toFixed(1) + "s -> " + clampedEnd.toFixed(1) + "s)" +
" [" + playIndex + "/" + totalPlays + "]";
}
};
startPreviewPlay(1);
player?.closest("section")?.scrollIntoView({ behavior: "smooth", block: "nearest" });
})
.catch((err) => {

Loading…
Cancel
Save