feat(voices): consent and provenance field per voice

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-04 16:39:32 +02:00
parent b36423457b
commit 781bf6e7f2
12 changed files with 401 additions and 1 deletions

View File

@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased] ## [Unreleased]
### Added ### 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`. - **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 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`. - **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`.

View File

@ -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 ## 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). **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).

View 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
View 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": ""}

View File

@ -167,6 +167,9 @@ def _validate_voice_meta(meta: dict) -> None:
raise ValueError(f"Voice metadata field '{field}' must be boolean") raise ValueError(f"Voice metadata field '{field}' must be boolean")
if "audio_problems" in meta and not isinstance(meta["audio_problems"], list): if "audio_problems" in meta and not isinstance(meta["audio_problems"], list):
raise ValueError("Voice metadata field 'audio_problems' must be a 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"): for field in ("loudness", "benchmark"):
if field in meta and meta[field] is not None and not isinstance(meta[field], dict): 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") 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]: def _voice_package_paths(audio: Path) -> list[Path]:
parent = audio.parent parent = audio.parent
paths = [] 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}" p = parent / f"{audio.stem}{sfx}"
if p.exists(): if p.exists():
paths.append(p) paths.append(p)

View File

@ -494,6 +494,63 @@ async def update_voice_meta(request: Request):
if wav.with_suffix(".reference.txt").exists() else ""} 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") @router.post("/api/voices/sync-folders")
async def sync_voice_folders(): async def sync_voice_folders():
settings = _load_settings() settings = _load_settings()

View File

@ -651,6 +651,8 @@ async def quick_import_voice(request: Request):
for field in ("name", "tag", "group", "origin", "note", "source"): for field in ("name", "tag", "group", "origin", "note", "source"):
if data.get(field) is not None: if data.get(field) is not None:
meta[field] = str(data.get(field) or "").strip()[:500] 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: if auto_prepare and not transcript:
meta["reference_transcript_status"] = "pending" meta["reference_transcript_status"] = "pending"
meta["import_preparation"] = "queued" meta["import_preparation"] = "queued"

View File

@ -118,6 +118,7 @@ function selectVoice(wrap) {
</span> </span>
<span class="insp-gender-label" title="Click to cycle gender">${escHtml(genderLabelHtml)}</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> <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>` : ''} ${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-wrap">
<span class="insp-stars">${makeStars(rating)}</span> <span class="insp-stars">${makeStars(rating)}</span>
@ -490,6 +491,9 @@ function selectVoice(wrap) {
} catch(e) { toast('Save failed: ' + e.message, 'error'); } } catch(e) { toast('Save failed: ' + e.message, 'error'); }
}); });
// ── Source & consent (provenance) ─────────────────────────────────────────
attachProvenancePanel(v, voiceId, body);
const maintTitle = body.querySelector('.opt-maintenance .opt-group-title'); const maintTitle = body.querySelector('.opt-maintenance .opt-group-title');
if (maintTitle) { 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>`; 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 &amp; 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 &amp; 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.selectVoice = selectVoice;
root.TTVC = root.TTVC || {}; root.TTVC = root.TTVC || {};
root.TTVC.voiceInspector = root.TTVC.voiceInspector || {}; root.TTVC.voiceInspector = root.TTVC.voiceInspector || {};

View File

@ -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>` : ''} ${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> <div class="vr-rating">${starsHtml}</div>
<span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span> <span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span>
${window.provenanceBadge ? window.provenanceBadge(v) : ''}
</div> </div>
</div> </div>
<div class="vr-play-group"> <div class="vr-play-group">

View File

@ -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-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-scope-actions { display:flex; flex-wrap:wrap; gap:8px; }
#lib-chars-list { min-width:0; max-width:100%; } #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; }

View 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()

View 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');
});