feat(voices): consent and provenance field per voice
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b36423457b
commit
781bf6e7f2
@ -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 `<voice>.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`.
|
||||
|
||||
11
Manual.md
11
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 `<voice>.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).
|
||||
|
||||
13
architecture/voice-provenance.md
Normal file
13
architecture/voice-provenance.md
Normal file
@ -0,0 +1,13 @@
|
||||
# Voice provenance and consent
|
||||
|
||||
Data: `provenance` object in `<voice>.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 `<voice>.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.
|
||||
55
core/provenance.py
Normal file
55
core/provenance.py
Normal file
@ -0,0 +1,55 @@
|
||||
"""Consent/provenance sidecar field for voices (stored in <voice>.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": ""}
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -118,6 +118,7 @@ function selectVoice(wrap) {
|
||||
</span>
|
||||
<span class="insp-gender-label" title="Click to cycle gender">${escHtml(genderLabelHtml)}</span>
|
||||
<span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span>
|
||||
${window.provenanceBadge ? window.provenanceBadge(v) : ''}
|
||||
${inspBenchText !== '-' ? `<span class="insp-bench-chip ${escHtml(inspBenchCls)}" title="${escHtml(inspBenchTitle)}"><span class="mdi mdi-timer-outline"></span> ${escHtml(inspBenchText)}</span>` : ''}
|
||||
<span class="insp-stars-wrap">
|
||||
<span class="insp-stars">${makeStars(rating)}</span>
|
||||
@ -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 = `<span class="opt-chevron"></span><span class="opt-title-text">Loudness <span class="opt-group-meta">Current ${escHtml(dbfs)} dBFS</span></span>`;
|
||||
@ -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'
|
||||
? '<span class="vl-prov-badge" title="Source and consent not recorded - open the voice to set them"><span class="mdi mdi-alert-outline" aria-hidden="true"></span> Source unknown</span>'
|
||||
: '';
|
||||
|
||||
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 = `
|
||||
<div class="insp-persona-head">
|
||||
<h4 class="insp-persona-title" id="${id}-h">Source & consent</h4>
|
||||
<span class="insp-persona-hint">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.</span>
|
||||
</div>
|
||||
<label for="${id}-basis">Basis</label>
|
||||
<select id="${id}-basis" class="insp-prov-input">${PROV_BASES.map(([k, l]) =>
|
||||
`<option value="${k}"${(p.basis || 'unknown') === k ? ' selected' : ''}>${l}</option>`).join('')}</select>
|
||||
<label for="${id}-source">Source (URL or description, no passwords)</label>
|
||||
<input id="${id}-source" class="insp-prov-input" type="text" maxlength="500" autocomplete="off" value="${escHtml(p.source || '')}">
|
||||
<label for="${id}-licence">Licence</label>
|
||||
<input id="${id}-licence" class="insp-prov-input" type="text" maxlength="300" autocomplete="off" value="${escHtml(p.licence || '')}">
|
||||
<label for="${id}-note">Consent statement or note (typed alternative to a recording)</label>
|
||||
<textarea id="${id}-note" class="insp-persona-input" rows="2" maxlength="1000">${escHtml(p.note || '')}</textarea>
|
||||
<div class="insp-prov-rec">
|
||||
<button type="button" class="btn-secondary insp-prov-rec-btn" aria-pressed="false">Record spoken consent</button>
|
||||
<audio class="insp-prov-audio" controls ${p.recorded_consent ? `src="/api/voices/${encodeURIComponent(voiceId)}/consent-recording"` : 'hidden'} aria-label="Recorded consent statement"></audio>
|
||||
</div>
|
||||
<div class="insp-persona-actions">
|
||||
<button type="button" class="btn-primary btn-sm insp-prov-save">Save source & consent</button>
|
||||
<span class="insp-persona-status" role="status" aria-live="polite"></span>
|
||||
</div>`;
|
||||
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 || {};
|
||||
|
||||
@ -2234,6 +2234,7 @@ function makeVoiceRow(v) {
|
||||
${benchText !== '-' ? `<span class="vl-bench-chip ${benchCls}" title="${escHtml(benchTitle)}"><span class="mdi mdi-timer-outline"></span> ${escHtml(benchText.split(' ')[0])}</span>` : ''}
|
||||
<div class="vr-rating">${starsHtml}</div>
|
||||
<span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span>
|
||||
${window.provenanceBadge ? window.provenanceBadge(v) : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="vr-play-group">
|
||||
|
||||
@ -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; }
|
||||
|
||||
99
tests/test_voice_provenance.py
Normal file
99
tests/test_voice_provenance.py
Normal file
@ -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()
|
||||
55
tests/voice-provenance.spec.js
Normal file
55
tests/voice-provenance.spec.js
Normal file
@ -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');
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user