diff --git a/kazeia_central/voice/orchestrator.py b/kazeia_central/voice/orchestrator.py
index 9ba816e..0eedec4 100644
--- a/kazeia_central/voice/orchestrator.py
+++ b/kazeia_central/voice/orchestrator.py
@@ -66,8 +66,10 @@ def list_voices(adb: Adb, store: Store, serial: str) -> list[dict[str, Any]]:
"""Inventaire voix de la tablette croisé avec l'archive store (statut, verrou,
profils utilisateurs) par voix."""
rows = _provider_voices(adb, serial)
- ov = transfer.omnivoice_dir_for(rows[0].wav_path) if rows else None
- deployed = transfer.deployed_ovsp(adb, serial, ov) if ov else set()
+ try:
+ deployed = transfer.deployed_ovsp(adb, serial, _patient_omnivoice_dir(adb, serial))
+ except Exception:
+ deployed = set()
recs = {r["voice_id"]: r for r in store.voices(serial)} if store.is_unlocked else {}
used_by, _ = _profiles_using(adb, serial)
out = []
@@ -159,9 +161,7 @@ def enroll_deploy(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, voic
Le WAV reste archivé côté PC → suppression device réversible par ré-enrôlement."""
data = _ensure_wav_archived(adb, store, serial, voice_id, now=now)
wav_path = _find_wav_path(adb, serial, voice_id)
- ov = transfer.omnivoice_dir_for(wav_path) if wav_path else None
- if not ov:
- raise ValueError(f"dossier omnivoice indéterminé pour {voice_id}")
+ ov = _patient_omnivoice_dir(adb, serial)
with tempfile.TemporaryDirectory() as td:
local = os.path.join(td, f"{voice_id}.wav")
diff --git a/kazeia_central/web/index.html b/kazeia_central/web/index.html
index 95718d1..8bcffbd 100644
--- a/kazeia_central/web/index.html
+++ b/kazeia_central/web/index.html
@@ -72,6 +72,7 @@
console de flotte
+ 🎙 Voix
▦ Vue parc
↻ Parc
@@ -259,6 +260,109 @@ async function selectDevice(serial) {
d.innerHTML = `${state}${updates}${rag}${profiles}${convs}${crashes}
`;
}
+// ---- panneau voix (enrôlement OmniVoice) ---------------------------------
+const SCOPE_COLOR = { exclusive:"var(--warn)", global:"var(--ok)", pending:"var(--muted)" };
+function scopeBadge(s) {
+ return `${s||"?"} `;
+}
+
+async function voiceView() {
+ if (!selected) { $("detail").innerHTML = `← sélectionne d'abord une tablette
`; return; }
+ if (!unlocked) { $("detail").innerHTML = `Voix Déverrouille le store (colonne de gauche) pour gérer les voix.
`; return; }
+ const serial = selected;
+ const d = $("detail");
+ d.innerHTML = `chargement des voix de ${serial} …
`;
+ const [auto, admin, deployed] = await Promise.all([
+ api("/voices/autosync").catch(e=>({error:e.message})),
+ api(`/voices/${serial}/admin`).catch(e=>({error:e.message})),
+ api(`/voices/${serial}`).catch(e=>({error:e.message})),
+ ]);
+
+ // 1) déclencheur auto
+ const aStat = auto?.devices?.[serial];
+ const autoCard = card("Déclencheur automatique",
+ auto.error ? `${auto.error}
` :
+ `état ${dot(auto.enabled)}${auto.enabled?"armé":"désarmé"}
+
+ ${auto.enabled?"Désarmer":"Armer"}
+ supprimer le WAV device après
+
+ À l'armement, toute tablette qui se (re)connecte est enrôlée automatiquement.
+ ${aStat?` Dernière sync ${serial} : ${aStat.state} ${aStat.enrolled!=null?` (${aStat.enrolled}/${aStat.total})`:""}`:""}
`);
+
+ // 2) voix admin à enrôler
+ const av = Array.isArray(admin) ? admin : [];
+ const adminCard = card(`Voix enregistrées — admin (${av.length})`,
+ admin.error ? `${admin.error}
` :
+ (av.length ? `
+ nom portée propriétaire statut consentement
+ ${av.map(v=>`
+ ${v.name||v.voice_id} ${v.voice_id}
+ ${scopeBadge(v.scope)}
+ ${v.owner_name||v.owner_profile_id||"—"}
+ ${v.status}${v.deployed_on_device?" ✓":""}
+ ${(v.reference_text||"—").slice(0,40)}${(v.reference_text||"").length>40?"…":""}
+ `).join("")}
+
` : `aucune voix enregistrée côté admin `) +
+ `
+ Synchroniser (enrôler + déployer)
+ supprimer le WAV device après
+
+
`, "full");
+
+ // 3) voix déployées (verrou)
+ const dv = Array.isArray(deployed) ? deployed : [];
+ const depCard = card(`Voix déployées sur la tablette (${dv.filter(v=>v.deployed_on_device).length})`,
+ deployed.error ? `${deployed.error}
` :
+ (dv.length ? `
+ voix statut verrou profils
+ ${dv.map(v=>`
+ ${v.voice_id}
+ ${v.deployed_on_device?dot(true)+"déployée":v.status}
+ ${v.locked_profile_id?`🔒 ${v.locked_profile_id}`:"généraliste "}
+ ${(v.used_by_profiles||[]).join(", ")||"—"}
+ ${v.locked_profile_id
+ ? `déverrouiller `
+ : `verrouiller… `}
+ `).join("")}
+
` : `aucune voix `), "full");
+
+ d.innerHTML = `${autoCard}${adminCard}${depCard}
`;
+}
+
+async function toggleAutosync(on) {
+ const del = $("auto_del")?.checked || false;
+ await api("/voices/autosync", {method:"POST", headers:{"content-type":"application/json"},
+ body: JSON.stringify({enabled:on, delete_source:del})});
+ voiceView();
+}
+
+async function syncAdmin(serial) {
+ const del = $("sync_del")?.checked || false;
+ const btn = $("syncbtn"), msg = $("syncmsg");
+ btn.disabled = true; msg.textContent = "enrôlement en cours… (≈1 min par voix, ne ferme pas)";
+ try {
+ const rep = await api(`/voices/${serial}/admin/sync`, {method:"POST",
+ headers:{"content-type":"application/json"}, body: JSON.stringify({delete_source:del})});
+ const ok = rep.filter(r=>r.ok && r.deployed).length;
+ msg.textContent = `${ok}/${rep.length} voix déployée(s).`;
+ await voiceView();
+ } catch (e) { msg.textContent = "erreur : " + e.message; btn.disabled = false; }
+}
+
+async function lockVoice(serial, vid) {
+ const pid = prompt(`Verrouiller « ${vid} » sur quel profil patient ? (profile_id)`);
+ if (!pid) return;
+ await api(`/voices/${serial}/${vid}/lock`, {method:"POST",
+ headers:{"content-type":"application/json"}, body: JSON.stringify({profile_id:pid})});
+ voiceView();
+}
+
+async function unlockVoice(serial, vid) {
+ await api(`/voices/${serial}/${vid}/unlock`, {method:"POST"});
+ voiceView();
+}
+
(async () => { await refreshLock(); await loadDevices(); })();