diff --git a/kazeia_central/api/app.py b/kazeia_central/api/app.py index 486b06e..90bd993 100644 --- a/kazeia_central/api/app.py +++ b/kazeia_central/api/app.py @@ -25,6 +25,8 @@ from ..adb import Adb, AdbError from ..fleet import device_overview, fan_out from ..provider import ProviderClient from ..store import BadPassword, Store, StoreLocked +from ..voice import orchestrator as vo +from ..voice.bridge import VoiceBridge, VoiceWorkerError _WEB_DIR = Path(__file__).resolve().parent.parent / "web" # Base chiffrée locale. Défaut sous data/ (ignoré par .gitignore — jamais commité). @@ -45,6 +47,11 @@ class _Label(BaseModel): label: str +class _Enroll(BaseModel): + transcription: str + delete_source: bool = False # suppression du WAV device = opt-in explicite + + def _install_basic_auth(app: FastAPI) -> None: """Auth HTTP Basic optionnelle, active SEULEMENT si KAZEIA_USER+KAZEIA_PASS sont définis. Couvre tout (UI statique incluse) via middleware. Pensée pour @@ -70,11 +77,13 @@ def _install_basic_auth(app: FastAPI) -> None: return await call_next(request) -def create_app(adb: Adb | None = None, store: Store | None = None) -> FastAPI: +def create_app(adb: Adb | None = None, store: Store | None = None, + bridge: VoiceBridge | None = None) -> FastAPI: adb = adb or Adb() if store is None: _STORE_PATH.parent.mkdir(parents=True, exist_ok=True) store = Store(_STORE_PATH) + bridge = bridge or VoiceBridge() app = FastAPI(title="Kazeia-central", version="0.0.1") _install_basic_auth(app) @@ -88,6 +97,8 @@ def create_app(adb: Adb | None = None, store: Store | None = None) -> FastAPI: raise HTTPException(status_code=502, detail=str(e)) from e except StoreLocked as e: raise HTTPException(status_code=423, detail=str(e)) from e + except VoiceWorkerError as e: + raise HTTPException(status_code=503, detail=f"encodeur voix: {e}") from e except IndexError as e: # endpoint a renvoyé 0 ligne raise HTTPException(status_code=404, detail="aucune donnée") from e @@ -198,6 +209,27 @@ def create_app(adb: Adb | None = None, store: Store | None = None) -> FastAPI: now=_now_ms()) return report + # ---- voix (enrôlement CosyVoice : WAV device → .cvps → flotte) -------- + @app.get("/api/voices/health") + def voices_health(): + return {"encoder_available": bridge.available(), "cv_python": bridge.cv_python} + + @app.get("/api/voices/{serial}") + def voices_list(serial: str): + return _guard(lambda: vo.list_voices(adb, store, serial)) + + @app.post("/api/voices/{serial}/{voice_id}/prepare") + def voices_prepare(serial: str, voice_id: str): + # pull + archive chiffré + transcription Whisper (à relire avant enrôlement) + return _guard(lambda: vo.prepare(adb, store, bridge, serial, voice_id, + now=_now_ms(), actor=_OPERATOR)) + + @app.post("/api/voices/{serial}/{voice_id}/enroll") + def voices_enroll(serial: str, voice_id: str, body: _Enroll): + return _guard(lambda: vo.enroll_deploy( + adb, store, bridge, serial, voice_id, body.transcription, + delete_source=body.delete_source, now=_now_ms(), actor=_OPERATOR)) + # UI web locale servie à la racine — APRÈS les routes /api pour ne pas les # masquer. `html=True` → sert index.html sur "/". if _WEB_DIR.is_dir(): diff --git a/kazeia_central/store/db.py b/kazeia_central/store/db.py index 8fd9b4b..a8c0330 100644 --- a/kazeia_central/store/db.py +++ b/kazeia_central/store/db.py @@ -20,6 +20,7 @@ connexion ouverte avec `check_same_thread=False` et accès sérialisé par un `R from __future__ import annotations +import hashlib import json import sqlite3 import threading @@ -64,6 +65,21 @@ CREATE TABLE IF NOT EXISTS audit ( target TEXT, detail TEXT -- métadonnée d'audit (pas de PII) → clair ); +CREATE TABLE IF NOT EXISTS voices ( + serial TEXT NOT NULL, + voice_id TEXT NOT NULL, + transcription_enc BLOB, -- texte parlé (PII possible) → chiffré + language TEXT, + source_wav_path TEXT, -- chemin device d'origine + wav_file TEXT, -- WAV chiffré archivé (relatif au dossier voices/) + wav_sha256 TEXT, -- intégrité du WAV d'origine + cvps_bytes INTEGER, + archived_at INTEGER, -- WAV récupéré + archivé sur le PC + enrolled_at INTEGER, -- .cvps produit + deployed_at INTEGER, -- .cvps poussé sur la tablette + wav_deleted_at INTEGER, -- WAV supprimé de la tablette + PRIMARY KEY (serial, voice_id) +); """ @@ -251,6 +267,99 @@ class Store: ).fetchone() return row["m"] if row else None + # ---- archive voix (enrôlement, §6 VOICE_ENROLLMENT_SPEC) -------------- + def _voices_dir(self) -> Path: + d = Path(self.path).parent / "voices" + d.mkdir(parents=True, exist_ok=True) + return d + + def _voice_upsert(self, serial: str, voice_id: str, **cols: Any) -> None: + """Upsert partiel d'une ligne voix (colonnes données seulement).""" + keys = list(cols) + sets = ", ".join(f"{k}=excluded.{k}" for k in keys) + self._db.execute( + f"INSERT INTO voices(serial, voice_id, {', '.join(keys)}) " + f"VALUES(?, ?, {', '.join('?' for _ in keys)}) " + f"ON CONFLICT(serial, voice_id) DO UPDATE SET {sets}", + (serial, voice_id, *[cols[k] for k in keys]), + ) + self._db.commit() + + def archive_voice_wav(self, serial: str, voice_id: str, wav_bytes: bytes, *, + source_wav_path: str | None, now: int) -> str: + """Chiffre + archive le WAV d'origine sur le PC (le « garder sur Kazeia-central » + une fois supprimé de la tablette). Renvoie le chemin du fichier chiffré.""" + v = self._require_vault() + sha = hashlib.sha256(wav_bytes).hexdigest() + dest = self._voices_dir() / serial / f"{voice_id}.wav.enc" + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(v.seal_bytes(wav_bytes)) + with self._lock: + self._voice_upsert(serial, voice_id, source_wav_path=source_wav_path, + wav_file=str(dest.relative_to(self._voices_dir())), + wav_sha256=sha, archived_at=now) + return str(dest) + + def voice_wav_bytes(self, serial: str, voice_id: str) -> bytes | None: + """Relit le WAV archivé déchiffré (pour ré-enrôlement).""" + v = self._require_vault() + with self._lock: + row = self._db.execute( + "SELECT wav_file FROM voices WHERE serial=? AND voice_id=?", + (serial, voice_id)).fetchone() + if not row or not row["wav_file"]: + return None + return v.open_bytes((self._voices_dir() / row["wav_file"]).read_bytes()) + + def set_voice_transcription(self, serial: str, voice_id: str, text: str, + language: str | None, *, now: int) -> None: + v = self._require_vault() + with self._lock: + self._voice_upsert(serial, voice_id, transcription_enc=v.seal(text), + language=language) + + def mark_voice_enrolled(self, serial: str, voice_id: str, cvps_bytes: int, *, now: int) -> None: + with self._lock: + self._voice_upsert(serial, voice_id, cvps_bytes=cvps_bytes, enrolled_at=now) + + def mark_voice_deployed(self, serial: str, voice_id: str, *, now: int) -> None: + with self._lock: + self._voice_upsert(serial, voice_id, deployed_at=now) + + def mark_voice_wav_deleted(self, serial: str, voice_id: str, *, now: int) -> None: + with self._lock: + self._voice_upsert(serial, voice_id, wav_deleted_at=now) + + def voice_record(self, serial: str, voice_id: str) -> dict[str, Any] | None: + v = self._require_vault() + with self._lock: + row = self._db.execute( + "SELECT * FROM voices WHERE serial=? AND voice_id=?", + (serial, voice_id)).fetchone() + if not row: + return None + d = dict(row) + d["transcription"] = v.open(d.pop("transcription_enc")) + d["has_wav"] = bool(d.get("wav_file")) + return d + + def voices(self, serial: str | None = None) -> list[dict[str, Any]]: + v = self._require_vault() + with self._lock: + q = "SELECT * FROM voices" + args: tuple = () + if serial is not None: + q += " WHERE serial=?" + args = (serial,) + rows = self._db.execute(q + " ORDER BY serial, voice_id", args).fetchall() + out = [] + for r in rows: + d = dict(r) + d["transcription"] = v.open(d.pop("transcription_enc")) + d["has_wav"] = bool(d.get("wav_file")) + out.append(d) + return out + # ---- audit (§8) ------------------------------------------------------- def audit(self, action: str, *, actor: str | None = None, target: str | None = None, detail: dict[str, Any] | None = None, now: int) -> None: diff --git a/kazeia_central/voice/orchestrator.py b/kazeia_central/voice/orchestrator.py new file mode 100644 index 0000000..bb01e77 --- /dev/null +++ b/kazeia_central/voice/orchestrator.py @@ -0,0 +1,136 @@ +"""Orchestration de l'enrôlement vocal (le workflow complet). + +Compose : provider (`/voices` → wav_path), transfert adb, bridge (worker cv_venv), +store chiffré. Tourne sous l'API py3.14 ; le calcul lourd est délégué au worker. + +Workflow (VOICE_ENROLLMENT_SPEC §1, décisions 2026-06-19) : + connexion → lister voix sans .cvps → pull WAV → ARCHIVER (store chiffré) → + transcrire (Whisper, relecture opérateur) → enrôler (.cvps) → pousser sur la + tablette → SUPPRIMER le WAV de la tablette (gardé sur Kazeia-central). + +Découpé pour permettre la relecture de la transcription : `prepare` (pull+archive+ +transcribe) puis `enroll_deploy` (enroll+push+delete). Le WAV est archivé DÈS prepare +→ jamais de suppression device sans copie PC. +""" + +from __future__ import annotations + +import os +import tempfile +from typing import Any + +from ..adb import Adb +from ..provider import ProviderClient +from ..store import Store +from . import transfer +from .bridge import VoiceBridge + + +def _provider_voices(adb: Adb, serial: str): + return ProviderClient(adb, serial).voices() + + +def _find_wav_path(adb: Adb, serial: str, voice_id: str) -> str | None: + for v in _provider_voices(adb, serial): + if v.id == voice_id: + return v.wav_path + return None + + +def _status(deployed: bool, rec: dict) -> str: + if deployed: + return "deployed" + if rec.get("enrolled_at"): + return "enrolled" + if rec.get("archived_at"): + return "archived" + return "pending" + + +def list_voices(adb: Adb, store: Store, serial: str) -> list[dict[str, Any]]: + """Inventaire voix de la tablette croisé avec l'archive store (statut par voix).""" + rows = _provider_voices(adb, serial) + cosy = transfer.cosyvoice_dir_for(rows[0].wav_path) if rows else None + deployed = transfer.deployed_cvps(adb, serial, cosy) if cosy else set() + recs = {r["voice_id"]: r for r in store.voices(serial)} if store.is_unlocked else {} + out = [] + for v in rows: + rec = recs.get(v.id, {}) + is_dep = v.id in deployed + out.append({ + "voice_id": v.id, + "wav_path": v.wav_path, + "wav_on_device": v.wav_exists, + "deployed_on_device": is_dep, + "archived": bool(rec.get("archived_at")), + "enrolled": bool(rec.get("enrolled_at")), + "wav_deleted": bool(rec.get("wav_deleted_at")), + "transcription": rec.get("transcription"), + "language": rec.get("language"), + "status": _status(is_dep, rec), + }) + return out + + +def _ensure_wav_archived(adb: Adb, store: Store, serial: str, voice_id: str, *, + now: int) -> bytes: + """Garantit que le WAV est archivé (chiffré) côté PC ; le renvoie déchiffré.""" + data = store.voice_wav_bytes(serial, voice_id) + if data is not None: + return data + wav_path = _find_wav_path(adb, serial, voice_id) + if not wav_path: + raise ValueError(f"voix introuvable sur la tablette: {voice_id}") + with tempfile.TemporaryDirectory() as td: + local = os.path.join(td, f"{voice_id}.wav") + transfer.pull_wav(adb, serial, wav_path, local) + data = open(local, "rb").read() + store.archive_voice_wav(serial, voice_id, data, source_wav_path=wav_path, now=now) + return data + + +def prepare(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, voice_id: str, *, + now: int, actor: str | None = None) -> dict[str, Any]: + """Pull + archive (chiffré) + transcription Whisper. Renvoie le texte à relire.""" + data = _ensure_wav_archived(adb, store, serial, voice_id, now=now) + with tempfile.TemporaryDirectory() as td: + local = os.path.join(td, f"{voice_id}.wav") + open(local, "wb").write(data) + tr = bridge.transcribe(local) + store.set_voice_transcription(serial, voice_id, tr["text"], tr.get("language"), now=now) + store.audit("voice_prepare", actor=actor, target=f"{serial}/{voice_id}", now=now) + return {"voice_id": voice_id, "transcription": tr["text"], + "language": tr.get("language"), "model": tr.get("model")} + + +def enroll_deploy(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, voice_id: str, + transcription: str, *, delete_source: bool, now: int, + actor: str | None = None) -> dict[str, Any]: + """Enrôle (.cvps) → pousse sur la tablette → (option) supprime le WAV device. + 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) + cosy = transfer.cosyvoice_dir_for(wav_path) if wav_path else None + if not cosy: + raise ValueError(f"dossier cosyvoice indéterminé pour {voice_id}") + + with tempfile.TemporaryDirectory() as td: + local = os.path.join(td, f"{voice_id}.wav") + open(local, "wb").write(data) + cvps = os.path.join(td, f"{voice_id}.cvps") + info = bridge.enroll(local, transcription, cvps) + store.set_voice_transcription(serial, voice_id, transcription, None, now=now) + store.mark_voice_enrolled(serial, voice_id, info["bytes"], now=now) + remote = transfer.push_cvps(adb, serial, cvps, cosy, voice_id) + store.mark_voice_deployed(serial, voice_id, now=now) + store.audit("voice_enroll_deploy", actor=actor, target=f"{serial}/{voice_id}", + detail={"cvps_bytes": info["bytes"]}, now=now) + + result = {"voice_id": voice_id, "cvps_bytes": info["bytes"], + "deployed_to": remote, "wav_deleted": False} + if delete_source and wav_path: + transfer.delete_device_wav(adb, serial, wav_path) + store.mark_voice_wav_deleted(serial, voice_id, now=now) + store.audit("voice_wav_deleted", actor=actor, target=f"{serial}/{voice_id}", now=now) + result["wav_deleted"] = True + return result diff --git a/kazeia_central/voice/transfer.py b/kazeia_central/voice/transfer.py new file mode 100644 index 0000000..864a01b --- /dev/null +++ b/kazeia_central/voice/transfer.py @@ -0,0 +1,57 @@ +"""Transfert de fichiers voix entre la tablette et Kazeia-central (adb). + +Py3.14-safe (pas de torch) : seulement des opérations adb. Le chemin des fichiers est +**device-dépendant** → on part toujours de la colonne `wav_path` que le provider +`/voices` renvoie (autorité), jamais d'un chemin codé en dur (cf. découverte du test : +WAV sous `/data/local/tmp/kazeia/…` en dev, `Android/data/com.kazeia/…` en prod). +""" + +from __future__ import annotations + +import posixpath + +from ..adb import Adb, AdbError + +# Le provider expose VOICE_WAV_DIR = "${MODELS_DIR}/../voix/voix" → ce marqueur +# permet d'extraire MODELS_DIR depuis un wav_path, donc le dossier cosyvoice cible. +_WAV_MARKER = "/../voix/voix/" + + +def cosyvoice_dir_for(wav_path: str) -> str: + """Dossier device des `.cvps` (`/cosyvoice`) dérivé d'un `wav_path`.""" + if _WAV_MARKER in wav_path: + models = wav_path.split(_WAV_MARKER)[0] + return posixpath.join(models, "cosyvoice") + # Fallback : …/kazeia/voix/voix/.wav → …/kazeia/models/cosyvoice + voix_dir = posixpath.dirname(wav_path) + kazeia = posixpath.dirname(posixpath.dirname(voix_dir)) + return posixpath.join(kazeia, "models", "cosyvoice") + + +def deployed_cvps(adb: Adb, serial: str, cosyvoice_dir: str) -> set[str]: + """Ensemble des voice_id ayant un `.cvps` déjà déployé sur la tablette.""" + out = adb.shell(f"ls {cosyvoice_dir} 2>/dev/null", serial=serial) + return {line[:-5].strip() for line in out.splitlines() + if line.strip().endswith(".cvps")} + + +def pull_wav(adb: Adb, serial: str, wav_path: str, local_path: str) -> None: + adb.pull(wav_path, local_path, serial=serial) + + +def push_cvps(adb: Adb, serial: str, local_cvps: str, cosyvoice_dir: str, + voice_id: str) -> str: + """Pousse le `.cvps` dans le dossier cosyvoice de la tablette. Renvoie le chemin + device. Crée le dossier au besoin.""" + adb.shell(f"mkdir -p {cosyvoice_dir}", serial=serial) + remote = posixpath.join(cosyvoice_dir, f"{voice_id}.cvps") + adb.push(local_cvps, remote, serial=serial) + return remote + + +def delete_device_wav(adb: Adb, serial: str, wav_path: str) -> None: + """Supprime le WAV d'origine de la tablette (après archivage côté PC). + Vérifie le retour : `rm` adb échoue silencieusement (§9).""" + out = adb.shell(f"rm -f {wav_path} && echo OK || echo FAIL", serial=serial) + if "OK" not in out: + raise AdbError(f"suppression WAV échouée: {wav_path} ({out.strip()})") diff --git a/tests/test_voice.py b/tests/test_voice.py new file mode 100644 index 0000000..0d07926 --- /dev/null +++ b/tests/test_voice.py @@ -0,0 +1,147 @@ +"""Tests du sous-système voix : dérivation chemins, archive store, orchestration. + +Le moteur lourd (CosyVoice/Whisper) n'est PAS testé ici (validé live) — on injecte +un faux bridge et un faux adb pour prouver la COMPOSITION (workflow) sans le modèle. +""" + +import nacl.pwhash + +from kazeia_central.adb import Adb +from kazeia_central.adb.client import AdbDevice +from kazeia_central.store import Store +from kazeia_central.voice import orchestrator as vo +from kazeia_central.voice import transfer + +_OPS = nacl.pwhash.argon2id.OPSLIMIT_MIN +_MEM = nacl.pwhash.argon2id.MEMLIMIT_MIN +NOW = 1_700_000_000_000 + + +def _store(tmp_path): + s = Store(tmp_path / "c.db", kdf_ops=_OPS, kdf_mem=_MEM) + s.unlock("pw") + return s + + +# ---- dérivation des chemins device --------------------------------------- +def test_cosyvoice_dir_marker(): + wp = "/data/local/tmp/kazeia/models/../voix/voix/amir.wav" + assert transfer.cosyvoice_dir_for(wp) == "/data/local/tmp/kazeia/models/cosyvoice" + + +def test_cosyvoice_dir_fallback_prod(): + wp = "/sdcard/Android/data/com.kazeia/files/kazeia/voix/voix/amir.wav" + assert transfer.cosyvoice_dir_for(wp) == \ + "/sdcard/Android/data/com.kazeia/files/kazeia/models/cosyvoice" + + +# ---- archive voix dans le store ------------------------------------------ +def test_voice_wav_archive_roundtrip_and_encrypted(tmp_path): + s = _store(tmp_path) + s.archive_voice_wav("D1", "amir", b"FAKEWAVDATA-amir", source_wav_path="/dev/amir.wav", now=NOW) + assert s.voice_wav_bytes("D1", "amir") == b"FAKEWAVDATA-amir" + # chiffré au repos : le blob brut ne contient pas le contenu + enc = (tmp_path / "voices" / "D1" / "amir.wav.enc").read_bytes() + assert b"FAKEWAVDATA" not in enc + rec = s.voice_record("D1", "amir") + assert rec["has_wav"] and rec["wav_sha256"] and rec["archived_at"] == NOW + + +def test_voice_transcription_encrypted(tmp_path): + s = _store(tmp_path) + s.archive_voice_wav("D1", "amir", b"x", source_wav_path=None, now=NOW) + s.set_voice_transcription("D1", "amir", "texte parlé confidentiel", "fr", now=NOW) + assert s.voice_record("D1", "amir")["transcription"] == "texte parlé confidentiel" + raw = (tmp_path / "c.db").read_bytes() + assert b"texte parl" not in raw # PII jamais en clair en base + + +def test_voice_status_marks(tmp_path): + s = _store(tmp_path) + s.archive_voice_wav("D1", "amir", b"x", source_wav_path=None, now=NOW) + s.mark_voice_enrolled("D1", "amir", 242000, now=NOW) + s.mark_voice_deployed("D1", "amir", now=NOW) + r = s.voice_record("D1", "amir") + assert r["cvps_bytes"] == 242000 and r["enrolled_at"] and r["deployed_at"] + + +# ---- orchestration (faux adb + faux bridge) ------------------------------ +class _VoiceAdb(Adb): + def __init__(self, voices, deployed=()): + super().__init__() + self._voices = voices # [(id, wav_path, wav_exists)] + self._deployed = list(deployed) + self.pushed = [] + self.deleted = [] + + def ready_devices(self): + return [AdbDevice("D1", "device", {})] + + def content_query(self, path, *, serial=None, where=None, expect_rows=False, _retry=True): + if path == "voices": + return [{"id": i, "wav_path": w, "wav_exists": 1} for i, w, _ in self._voices] + return [] + + def shell(self, command, *, serial=None, timeout=None): + if command.startswith("ls "): + return "\n".join(f"{d}.cvps" for d in self._deployed) + if "rm -f" in command: + self.deleted.append(command.split("rm -f ")[1].split(" &&")[0]) + return "OK" + return "" + + def pull(self, remote, local, *, serial=None): + open(local, "wb").write(b"WAV:" + remote.encode()) + + def push(self, local, remote, *, serial=None): + self.pushed.append((open(local, "rb").read(), remote)) + + +class _FakeBridge: + def transcribe(self, wav, model=None): + return {"text": "bonjour ceci est ma voix", "language": "fr", "model": "small"} + + def enroll(self, wav, text, out): + open(out, "wb").write(b"GGUF-CVPS-" + text.encode()[:5]) + return {"bytes": 242000, "feat_shape": [750, 80], "n_tokens": 375, "text_bytes": len(text)} + + +_WP = "/data/local/tmp/kazeia/models/../voix/voix/amir.wav" + + +def test_list_voices_status(tmp_path): + s = _store(tmp_path) + adb = _VoiceAdb([("amir", _WP, 1), ("damien", + "/data/local/tmp/kazeia/models/../voix/voix/damien.wav", 1)], + deployed=["damien"]) + out = {v["voice_id"]: v for v in vo.list_voices(adb, s, "D1")} + assert out["damien"]["status"] == "deployed" + assert out["amir"]["status"] == "pending" + + +def test_prepare_archives_and_transcribes(tmp_path): + s = _store(tmp_path) + adb = _VoiceAdb([("amir", _WP, 1)]) + res = vo.prepare(adb, s, _FakeBridge(), "D1", "amir", now=NOW) + assert res["transcription"].startswith("bonjour") + # WAV archivé (chiffré) + transcription posée + assert s.voice_wav_bytes("D1", "amir") == b"WAV:" + _WP.encode() + assert s.voice_record("D1", "amir")["transcription"].startswith("bonjour") + + +def test_enroll_deploy_pushes_and_optional_delete(tmp_path): + s = _store(tmp_path) + adb = _VoiceAdb([("amir", _WP, 1)]) + # sans suppression + r = vo.enroll_deploy(adb, s, _FakeBridge(), "D1", "amir", "ma voix", + delete_source=False, now=NOW) + assert r["cvps_bytes"] == 242000 and r["wav_deleted"] is False + assert adb.pushed and adb.pushed[0][1] == \ + "/data/local/tmp/kazeia/models/cosyvoice/amir.cvps" + assert s.voice_record("D1", "amir")["deployed_at"] == NOW + assert adb.deleted == [] + # avec suppression + r2 = vo.enroll_deploy(adb, s, _FakeBridge(), "D1", "amir", "ma voix", + delete_source=True, now=NOW) + assert r2["wav_deleted"] is True and adb.deleted == [_WP] + assert s.voice_record("D1", "amir")["wav_deleted_at"] == NOW