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
+
@@ -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é"}
+
+ + +
+
À 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 ? ` + + ${av.map(v=>` + + + + + + `).join("")} +
nomportéepropriétairestatutconsentement
${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?"…":""}
` : `aucune voix enregistrée côté admin`) + + `
+ + + +
`, "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 ? ` + + ${dv.map(v=>` + + + + + + `).join("")} +
voixstatutverrouprofils
${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 + ? `` + : ``}
` : `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(); })();