diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d82aab..08aa616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Added +- **Consent/provenance field per voice.** Each voice gets `provenance` in its `.meta.json` (`basis`: own_voice, consented, licensed, public_domain or unknown; `source`, `licence`, `note`, optional `recorded_consent`, `set_at`). Existing voices count as `unknown`; `quick-import-voice` prefills the source URL (basis stays `unknown`). New `PUT /api/voices/{id}/provenance` (enum, length caps, control characters stripped, http(s) URLs only, no credentials) and `POST`/`GET /api/voices/{id}/consent-recording` (audio only, 10 MB cap, stored as `.consent`, moves/deletes with the voice). The voice inspector has a "Source & consent" section (typed note or MediaRecorder recording) and voices without a recorded basis show a "Source unknown" badge. Generation is not blocked. Data stays local. Tests: `tests/test_voice_provenance.py`, `tests/voice-provenance.spec.js`. - **Seed Finder: Auto-pick best.** `POST /api/seed-autopick {voice, text, seeds|count}` (default 8, max 16) generates candidates sequentially through the existing temporary-voice path (voices.json is always restored), scores each 0-100 (ASR word accuracy, loudness/clipping, `audio_problems`) and returns all sub-scores plus the winner. Degrades to loudness/problems when STT is unavailable. The UI highlights the winner; saving stays a click on "Use seed". Tests: `tests/test_seed_autopick.py`, `tests/seed-autopick.spec.js`. - **Voice Design: attribute chips.** Toggle chips (gender, age, pitch, pace, accent/region, tone/timbre, emotion; German + English labels) above the design prompt add or remove only their own phrase in the free-text description. The textarea stays fully free-form. Test: `tests/design-chips.spec.js`. - **Voice health: timestamped audio problems.** `core.audio.audio_problems` flags long interior silence, clipping and sudden level jumps (`problems` list with `kind`, `start_s`, `end_s`, `detail`, plus one warning per kind) in the route-test voice health; existing keys unchanged. Computed on demand and cached by file mtime, not in the voice list. Shown in the routing test result and in the Try-out voice panel (new `GET /api/voices/{id}/health`); imports store the result in meta (`audio_problems`). Tests: `tests/test_audio_problems.py`, `tests/voice-health.spec.js`. diff --git a/Manual.md b/Manual.md index 036512a..c4fa1ab 100644 --- a/Manual.md +++ b/Manual.md @@ -14,6 +14,17 @@ Living manual. Every feature updates this file in the same commit as the code. --- +## Voice source & consent + +Purpose: record where a voice comes from and why you may use it (own voice, speaker consented, licensed, public domain, unknown). +Prerequisites: a voice in the Voice library. For a spoken consent statement: a microphone and browser permission. +Steps: open a voice in the Voice library, scroll to "Source & consent", pick the basis, add source (URL or description, never passwords), licence and a note, press "Save source & consent". Optionally press "Record spoken consent", say who you are and what you consent to, press "Stop recording"; the clip is saved and can be replayed. +Result: the "Source unknown" warning badge disappears from the list once a basis other than "Unknown" is saved. Imported voices get the source URL prefilled but stay "Unknown" until you decide. +Errors: invalid values (bad basis, over-long text, non-http(s) URL or URL with credentials) are reported next to the Save button. If the microphone is denied or missing, type a written consent statement into the note field instead. +Mobile/accessibility: all controls have labels and at least 44 px targets; status messages are announced; the typed note is a full alternative to recording. +Offline: works offline (local server only). +Privacy: everything stays on this server in the voice's `.meta.json` and `.consent` file; nothing is sent to external services. Generation is not blocked by this field (yet). + ## Voice health: audio problems **Purpose:** reference audio is checked for `silence` (over 1.5 s inside the clip), `clipping` (peaks at full scale for 0.1 s or more) and `level jump` (15 dB or more between adjacent half-second windows). diff --git a/architecture/voice-provenance.md b/architecture/voice-provenance.md new file mode 100644 index 0000000..d6c4f6c --- /dev/null +++ b/architecture/voice-provenance.md @@ -0,0 +1,13 @@ +# Voice provenance and consent + +Data: `provenance` object in `.meta.json`: `basis` (own_voice | consented | licensed | public_domain | unknown), `source`, `licence`, `note`, `recorded_consent` (original filename of the consent clip, set only by the upload route), `set_at` (UTC ISO). Absent means `unknown`. + +Validation: `core/provenance.py` (`validate_provenance`) at the API boundary and again in `_validate_voice_meta` on every meta save: enum, length caps (source 500, licence 300, note 1000), control characters stripped, URL-looking sources must be http(s) without credentials. + +API: `PUT /api/voices/{id}/provenance`; `POST|GET /api/voices/{id}/consent-recording` (audio ext + audio MIME, 10 MB cap, atomic write to `.consent`, served with `nosniff`/no-store). The clip is part of the voice package (`_voice_package_paths`), so rename/move/trash carry it. + +Imports: `quick-import-voice` sets `provenance` via `import_provenance(page_url or audio_url)`; basis stays `unknown`. + +UI: `static/js/voice-inspector.js` ("Source & consent" panel, `provenanceBadge`), list badge in `voice-library.js`. + +Not done: gating generation/agentic use on basis (see TODO.md); consent clip is not cryptographically bound to the voice. diff --git a/core/provenance.py b/core/provenance.py new file mode 100644 index 0000000..392d0f7 --- /dev/null +++ b/core/provenance.py @@ -0,0 +1,55 @@ +"""Consent/provenance sidecar field for voices (stored in .meta.json).""" +from __future__ import annotations + +import re +from datetime import datetime, timezone +from urllib.parse import urlsplit + +BASES = ("own_voice", "consented", "licensed", "public_domain", "unknown") +CONSENT_EXTS = {".webm", ".ogg", ".opus", ".wav", ".mp3", ".m4a"} +CONSENT_MIME = {".webm": "audio/webm", ".ogg": "audio/ogg", ".opus": "audio/ogg", ".wav": "audio/wav", + ".mp3": "audio/mpeg", ".m4a": "audio/mp4"} +MAX_CONSENT_BYTES = 10 * 1024 * 1024 +_LIMITS = {"source": 500, "licence": 300, "note": 1000} +_CTRL = re.compile(r"[\x00-\x08\x0b-\x1f\x7f]") + + +def _clean(value, limit: int) -> str: + if value is None: + return "" + if not isinstance(value, str): + raise ValueError("Provenance text fields must be strings") + value = _CTRL.sub("", value).strip() + if len(value) > limit: + raise ValueError(f"Provenance text is too long (max {limit})") + return value + + +def validate_provenance(data: dict, previous: dict | None = None) -> dict: + """Return a clean provenance dict or raise ValueError (-> HTTP 400).""" + if not isinstance(data, dict): + raise ValueError("Provenance must be an object") + basis = data.get("basis", "unknown") + if basis not in BASES: + raise ValueError("basis must be one of: " + ", ".join(BASES)) + out = {"basis": basis} + for field, limit in _LIMITS.items(): + out[field] = _clean(data.get(field), limit) + src = out["source"] + if re.match(r"^[a-z][a-z0-9+.-]*:", src, re.I): # looks like a URL: http(s) only, no credentials + parts = urlsplit(src) + if parts.scheme.lower() not in {"http", "https"} or not parts.hostname or parts.username or parts.password: + raise ValueError("source URL must be http(s) without credentials") + prev = previous or {} + if prev.get("recorded_consent"): # only the consent routes change the recording + out["recorded_consent"] = prev["recorded_consent"] + out["set_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds") + return out + + +def import_provenance(source_url: str) -> dict: + """Prefill for imports from a known source; basis stays unknown until a human decides.""" + try: + return validate_provenance({"basis": "unknown", "source": source_url}) + except ValueError: + return {"basis": "unknown", "source": "", "licence": "", "note": ""} diff --git a/core/voice.py b/core/voice.py index 5f4e8e8..dff5571 100644 --- a/core/voice.py +++ b/core/voice.py @@ -167,6 +167,9 @@ def _validate_voice_meta(meta: dict) -> None: raise ValueError(f"Voice metadata field '{field}' must be boolean") if "audio_problems" in meta and not isinstance(meta["audio_problems"], list): raise ValueError("Voice metadata field 'audio_problems' must be a list") + if "provenance" in meta: + from core.provenance import validate_provenance + validate_provenance(meta["provenance"]) for field in ("loudness", "benchmark"): if field in meta and meta[field] is not None and not isinstance(meta[field], dict): raise ValueError(f"Voice metadata field '{field}' must be an object") @@ -262,7 +265,7 @@ def _remove_audio_variants(parent: Path, voice_id: str, keep: Path | None = None def _voice_package_paths(audio: Path) -> list[Path]: parent = audio.parent paths = [] - for sfx in _AUDIO_EXTS + [".reference.txt", ".meta.json"] + _PICTURE_EXTS: + for sfx in _AUDIO_EXTS + [".reference.txt", ".meta.json", ".consent"] + _PICTURE_EXTS: p = parent / f"{audio.stem}{sfx}" if p.exists(): paths.append(p) diff --git a/routes/library.py b/routes/library.py index 1bb86c7..2e12c1d 100644 --- a/routes/library.py +++ b/routes/library.py @@ -494,6 +494,63 @@ async def update_voice_meta(request: Request): if wav.with_suffix(".reference.txt").exists() else ""} +def _provenance_voice(voice_id: str) -> tuple[Path, dict]: + settings = _load_settings() + wav = _find_voice_audio(voice_id, Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))) + if wav is None: + raise HTTPException(404, f"Voice '{voice_id}' not found") + return wav, settings + + +@router.put("/api/voices/{voice_id}/provenance") +async def put_voice_provenance(voice_id: str, request: Request): + from core.provenance import validate_provenance + wav, _ = _provenance_voice(voice_id) + try: + data = await request.json() + meta = _load_meta(wav) + meta["provenance"] = validate_provenance(data, meta.get("provenance")) + except ValueError as exc: + raise HTTPException(400, str(exc)) + _save_meta(wav, meta) + return {"ok": True, "provenance": meta["provenance"]} + + +@router.post("/api/voices/{voice_id}/consent-recording") +async def upload_consent_recording(voice_id: str, file: UploadFile = File(...)): + from core.provenance import CONSENT_EXTS, MAX_CONSENT_BYTES + wav, _ = _provenance_voice(voice_id) + suffix = _validated_upload_suffix(file.filename, default=".webm", allowed=CONSENT_EXTS) + if not (file.content_type or "").split(";")[0].strip().lower().startswith(("audio/", "video/webm")): + raise HTTPException(400, "Consent recording must be an audio file") + dest = wav.with_suffix(".consent") + tmp = dest.with_name(dest.name + ".part") + try: + with tmp.open("wb") as f: + _copy_limited(file.file, f, MAX_CONSENT_BYTES) + tmp.replace(dest) + finally: + tmp.unlink(missing_ok=True) + meta = _load_meta(wav) + prov = dict(meta.get("provenance") or {"basis": "unknown"}) + prov["recorded_consent"] = f"{wav.stem}{suffix}" # original type; served from the .consent file + meta["provenance"] = prov + _save_meta(wav, meta) + return {"ok": True, "provenance": prov} + + +@router.get("/api/voices/{voice_id}/consent-recording") +async def serve_consent_recording(voice_id: str): + from core.provenance import CONSENT_MIME + wav, _ = _provenance_voice(voice_id) + prov = _load_meta(wav).get("provenance") or {} + f = wav.with_suffix(".consent") + if not prov.get("recorded_consent") or not f.is_file(): + raise HTTPException(404, "No consent recording") + mime = CONSENT_MIME.get(Path(prov["recorded_consent"]).suffix.lower(), "audio/webm") + return FileResponse(str(f), media_type=mime, headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"}) + + @router.post("/api/voices/sync-folders") async def sync_voice_folders(): settings = _load_settings() diff --git a/routes/sources.py b/routes/sources.py index 9cbadc8..44b649b 100644 --- a/routes/sources.py +++ b/routes/sources.py @@ -651,6 +651,8 @@ async def quick_import_voice(request: Request): for field in ("name", "tag", "group", "origin", "note", "source"): if data.get(field) is not None: meta[field] = str(data.get(field) or "").strip()[:500] + from core.provenance import import_provenance + meta["provenance"] = import_provenance(str(data.get("page_url") or audio_url)) # basis stays "unknown" if auto_prepare and not transcript: meta["reference_transcript_status"] = "pending" meta["import_preparation"] = "queued" diff --git a/static/js/voice-inspector.js b/static/js/voice-inspector.js index 3029580..de6a7b9 100644 --- a/static/js/voice-inspector.js +++ b/static/js/voice-inspector.js @@ -118,6 +118,7 @@ function selectVoice(wrap) { ${escHtml(genderLabelHtml)} ${isClone ? 'Clone' : 'Design'} + ${window.provenanceBadge ? window.provenanceBadge(v) : ''} ${inspBenchText !== '-' ? ` ${escHtml(inspBenchText)}` : ''} ${makeStars(rating)} @@ -490,6 +491,9 @@ function selectVoice(wrap) { } catch(e) { toast('Save failed: ' + e.message, 'error'); } }); + // ── Source & consent (provenance) ───────────────────────────────────────── + attachProvenancePanel(v, voiceId, body); + const maintTitle = body.querySelector('.opt-maintenance .opt-group-title'); if (maintTitle) { maintTitle.innerHTML = `Loudness Current ${escHtml(dbfs)} dBFS`; @@ -518,6 +522,95 @@ function selectVoice(wrap) { } +const PROV_BASES = [['unknown', 'Unknown'], ['own_voice', 'My own voice'], ['consented', 'Speaker consented'], + ['licensed', 'Licensed'], ['public_domain', 'Public domain']]; + +// Warning chip for voices whose consent/source is not recorded (list rows + inspector). +root.provenanceBadge = v => ((v && v.provenance && v.provenance.basis) || 'unknown') === 'unknown' + ? ' Source unknown' + : ''; + +function attachProvenancePanel(v, voiceId, body) { + const p = v.provenance || {}; + const id = 'prov-' + voiceId.replace(/[^A-Za-z0-9_-]/g, '_'); + const panel = document.createElement('section'); + panel.className = 'insp-persona-panel insp-prov-panel'; + panel.setAttribute('aria-labelledby', id + '-h'); + panel.innerHTML = ` +
+

Source & consent

+ Where this voice comes from and why you may use it. Stored locally in this voice's metadata only; nothing is sent anywhere. Generation is not blocked by this. +
+ + + + + + + + +
+ + +
+
+ + +
`; + body.appendChild(panel); + const q = sel => panel.querySelector(sel); + const status = q('.insp-persona-status'); + const say = (msg, err) => { status.textContent = msg; status.style.color = err ? 'var(--danger, #dc2626)' : ''; }; + q('.insp-prov-save').addEventListener('click', async () => { + try { + const r = await fetch(`/api/voices/${encodeURIComponent(voiceId)}/provenance`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ basis: q(`#${id}-basis`).value, source: q(`#${id}-source`).value, + licence: q(`#${id}-licence`).value, note: q(`#${id}-note`).value }) }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.detail || r.statusText); + v.provenance = d.provenance; + if (typeof window.invalidateVoiceLibraryCache === 'function') window.invalidateVoiceLibraryCache(); + say('Saved.'); + } catch (e) { say('Save failed: ' + e.message, true); } + }); + const btn = q('.insp-prov-rec-btn'); + let rec = null; + btn.addEventListener('click', async () => { + if (rec) { rec.stop(); return; } + if (!navigator.mediaDevices || !window.MediaRecorder) { + say('Recording is not supported in this browser. Type the consent statement in the note field instead.', true); return; + } + let stream; + try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } + catch (e) { + say(e && e.name === 'NotAllowedError' + ? 'Microphone permission was denied. Allow it in the browser, or type the consent statement in the note field.' + : 'No usable microphone found. Type the consent statement in the note field instead.', true); return; + } + const chunks = []; + rec = new MediaRecorder(stream); + rec.ondataavailable = ev => { if (ev.data.size) chunks.push(ev.data); }; + rec.onstop = async () => { + stream.getTracks().forEach(t => t.stop()); + rec = null; btn.textContent = 'Record spoken consent'; btn.setAttribute('aria-pressed', 'false'); + try { + const fd = new FormData(); + fd.append('file', new Blob(chunks, { type: 'audio/webm' }), 'consent.webm'); + const r = await fetch(`/api/voices/${encodeURIComponent(voiceId)}/consent-recording`, { method: 'POST', body: fd }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.detail || r.statusText); + v.provenance = d.provenance; + const a = q('.insp-prov-audio'); a.src = `/api/voices/${encodeURIComponent(voiceId)}/consent-recording?t=${Date.now()}`; a.hidden = false; + say('Consent recording saved.'); + } catch (e) { say('Upload failed: ' + e.message, true); } + }; + rec.start(); btn.textContent = 'Stop recording'; btn.setAttribute('aria-pressed', 'true'); + say('Recording... say who you are, that you consent to this voice being cloned, and for what use.'); + }); +} + root.selectVoice = selectVoice; root.TTVC = root.TTVC || {}; root.TTVC.voiceInspector = root.TTVC.voiceInspector || {}; diff --git a/static/js/voice-library.js b/static/js/voice-library.js index d637e02..fd499c5 100644 --- a/static/js/voice-library.js +++ b/static/js/voice-library.js @@ -2234,6 +2234,7 @@ function makeVoiceRow(v) { ${benchText !== '-' ? ` ${escHtml(benchText.split(' ')[0])}` : ''}
${starsHtml}
${isClone ? 'Clone' : 'Design'} + ${window.provenanceBadge ? window.provenanceBadge(v) : ''}
diff --git a/static/style.css b/static/style.css index d853aad..b6ab938 100644 --- a/static/style.css +++ b/static/style.css @@ -6015,3 +6015,13 @@ html[data-mag-night] #toast.success { color: var(--green); } html[data-mag-night .lib-chars-scope-prompt p { margin:0 0 14px; color:var(--subtext); line-height:1.5; } .lib-chars-scope-actions { display:flex; flex-wrap:wrap; gap:8px; } #lib-chars-list { min-width:0; max-width:100%; } + +/* ── Voice provenance (source & consent) ─────────────────────────────────── */ +.vl-prov-badge { display: inline-flex; align-items: center; gap: 3px; font-size: 11px; font-weight: 600; + color: var(--warning, #b45309); border: 1px solid currentColor; border-radius: 10px; padding: 1px 7px; } +.insp-prov-panel label { font-size: 12px; font-weight: 600; color: var(--subtext); } +.insp-prov-input { width: 100%; min-height: 44px; font-size: 13px; padding: 8px 10px; font-family: var(--font); + border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); } +.insp-prov-input:focus-visible, .insp-prov-panel button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.insp-prov-panel button { min-height: 44px; min-width: 44px; } +.insp-prov-rec { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; } diff --git a/tests/test_voice_provenance.py b/tests/test_voice_provenance.py new file mode 100644 index 0000000..ebead2e --- /dev/null +++ b/tests/test_voice_provenance.py @@ -0,0 +1,99 @@ +"""Voice consent/provenance: validation, defaults, persistence, upload route (temp dirs only).""" +import asyncio +import io +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-prov-test-config-")) +from fastapi import HTTPException, UploadFile +from starlette.datastructures import Headers + +from core.provenance import import_provenance, validate_provenance +from core.voice import _load_meta, _save_meta, _voice_package_paths +import routes.library as lib + + +class ValidationTests(unittest.TestCase): + def test_defaults_and_cleaning(self): + p = validate_provenance({}) + self.assertEqual(p["basis"], "unknown") + p = validate_provenance({"basis": "licensed", "licence": " CC-BY\x00 4.0 ", "source": "https://x.org/a"}) + self.assertEqual(p["licence"], "CC-BY 4.0") + self.assertTrue(p["set_at"]) + + def test_rejects(self): + for bad in ({"basis": "stolen"}, {"note": "x" * 1001}, {"source": "javascript:alert(1)"}, + {"source": "ftp://x.org/a"}, {"source": "https://u:pw@x.org/"}, {"licence": 5}): + with self.assertRaises(ValueError, msg=str(bad)): + validate_provenance(bad) + + def test_recording_not_settable_by_client(self): + self.assertNotIn("recorded_consent", validate_provenance({"recorded_consent": "../../etc/passwd"})) + self.assertEqual(validate_provenance({}, {"recorded_consent": "a.webm"})["recorded_consent"], "a.webm") + + def test_import_prefill_stays_unknown(self): + p = import_provenance("https://voices.example/v/1") + self.assertEqual((p["basis"], p["source"]), ("unknown", "https://voices.example/v/1")) + self.assertEqual(import_provenance("ftp://bad")["source"], "") + + +class RouteTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.dir = Path(self.tmp.name) + self.wav = self.dir / "EN_F_Test.wav" + self.wav.write_bytes(b"RIFF") + patch = mock.patch.object(lib, "_load_settings", return_value={"voices_scan_dir": str(self.dir)}) + patch.start() + self.addCleanup(patch.stop) + self.addCleanup(self.tmp.cleanup) + + def _req(self, body): + class R: + async def json(_): + return body + return R() + + def test_put_persists_and_validates(self): + out = asyncio.run(lib.put_voice_provenance("EN_F_Test", self._req({"basis": "own_voice", "note": "me"}))) + self.assertEqual(_load_meta(self.wav)["provenance"]["basis"], "own_voice") + self.assertEqual(out["provenance"]["note"], "me") + with self.assertRaises(HTTPException) as cm: + asyncio.run(lib.put_voice_provenance("EN_F_Test", self._req({"basis": "nope"}))) + self.assertEqual(cm.exception.status_code, 400) + with self.assertRaises(HTTPException) as cm: + asyncio.run(lib.put_voice_provenance("missing", self._req({}))) + self.assertEqual(cm.exception.status_code, 404) + + def test_legacy_voice_has_no_provenance(self): + self.assertNotIn("provenance", _load_meta(self.wav)) # UI treats absent as unknown + + def _upload(self, name, ctype, data): + f = UploadFile(io.BytesIO(data), filename=name, headers=Headers({"content-type": ctype})) + return asyncio.run(lib.upload_consent_recording("EN_F_Test", f)) + + def test_consent_upload(self): + out = self._upload("c.webm", "audio/webm;codecs=opus", b"abc") + self.assertEqual(out["provenance"]["recorded_consent"], "EN_F_Test.webm") + self.assertTrue(self.wav.with_suffix(".consent").exists()) + self.assertIn(self.wav.with_suffix(".consent"), _voice_package_paths(self.wav)) + self.assertEqual(asyncio.run(lib.serve_consent_recording("EN_F_Test")).media_type, "audio/webm") + for name, ctype in (("c.exe", "audio/webm"), ("c.webm", "text/html"), ("../c.webm", "audio/webm")): + with self.assertRaises(HTTPException, msg=name): + self._upload(name, ctype, b"abc") + + def test_consent_too_large(self): + with mock.patch("core.provenance.MAX_CONSENT_BYTES", 2), self.assertRaises(HTTPException) as cm: + self._upload("c.webm", "audio/webm", b"abcdef") + self.assertEqual(cm.exception.status_code, 413) + self.assertFalse(self.wav.with_suffix(".consent").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/voice-provenance.spec.js b/tests/voice-provenance.spec.js new file mode 100644 index 0000000..002b058 --- /dev/null +++ b/tests/voice-provenance.spec.js @@ -0,0 +1,55 @@ +// @ts-check +// Source & consent panel in the voice inspector (all /api routes mocked; no live writes). +const { test, expect } = require('@playwright/test'); + +const VOICE = { id: 'EN_F_Test', path: '/voices/EN_F_Test.wav', file_type: 'wav', duration: 8, lang: 'EN', gender: 'F', enabled: true, has_ref: true, transcript: 'hi' }; + +test('unknown badge, validation error text and save payload', async ({ page }) => { + /** @type {any[]} */ const puts = []; + await page.route('**/api/**', route => route.request().method() === 'GET' ? route.fulfill({ json: {} }) : route.fulfill({ json: { ok: true } })); + await page.route('**/api/voices?*', route => route.fulfill({ json: [VOICE] })); + await page.route('**/api/voices', route => route.fulfill({ json: [VOICE] })); + await page.route('**/api/voices/EN_F_Test/provenance', route => { + puts.push(route.request().postDataJSON()); + return puts.length === 1 + ? route.fulfill({ status: 400, json: { detail: 'source URL must be http(s) without credentials' } }) + : route.fulfill({ json: { ok: true, provenance: { ...puts[1], set_at: 'now' } } }); + }); + await page.route('**/static/dist/main.min.js*', r => r.abort()); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 }); + await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); + await page.evaluate(() => window.navTo('s-voices')); + const row = page.locator('[data-id="EN_F_Test"]').first(); + await expect(row.locator('.vl-prov-badge')).toBeVisible(); + await row.locator('.vl-name').click(); + const panel = page.locator('.insp-prov-panel'); + await expect(panel.getByRole('heading', { name: 'Source & consent' })).toBeVisible(); + await panel.getByLabel('Basis').selectOption('licensed'); + await panel.getByLabel(/^Source/).fill('ftp://bad'); + const save = panel.getByRole('button', { name: /Save source/ }); + const box = await save.boundingBox(); + expect(box && box.height).toBeGreaterThanOrEqual(44); + await save.click(); + await expect(panel.getByRole('status')).toContainText('Save failed: source URL must be http(s)'); + await panel.getByLabel(/^Source/).fill('https://example.org/v'); + await save.click(); + await expect(panel.getByRole('status')).toHaveText('Saved.'); + expect(puts[1]).toMatchObject({ basis: 'licensed', source: 'https://example.org/v' }); +}); + +test('record button explains missing microphone permission', async ({ page }) => { + await page.route('**/api/**', route => route.request().method() === 'GET' ? route.fulfill({ json: {} }) : route.fulfill({ json: { ok: true } })); + await page.route('**/api/voices*', route => route.fulfill({ json: [VOICE] })); + await page.route('**/static/dist/main.min.js*', r => r.abort()); + await page.addInitScript(() => { + navigator.mediaDevices.getUserMedia = () => Promise.reject(Object.assign(new Error('x'), { name: 'NotAllowedError' })); + }); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 }); + await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); + await page.evaluate(() => window.navTo('s-voices')); + await page.locator('[data-id="EN_F_Test"]').first().locator('.vl-name').click(); + await page.getByRole('button', { name: 'Record spoken consent' }).click(); + await expect(page.locator('.insp-prov-panel').getByRole('status')).toContainText('Microphone permission was denied'); +});