# Speaker profiles — voice phase v1 (R2) Local voice fingerprinting and suggested speaker matches. Confirmed `transcriptx.speaker_profile_link.v1` records remain the **sole** cross-transcript identity authority. Voice artefacts are suggestive evidence only. Companion to [`speaker_profiles_v1.md`](speaker_profiles_v1.md). Same `speaker_profiles_dir`, same root `operations/` journal (`OperationEngine`). **No** `voice/operations/` subtree. Related storage: `docs/runtime/STORAGE.md`. --- ## Product rules (frozen) - Assistive by default — never auto-create, replace, or confirm a profile link from scores **unless** operator ingest `auto_link` is enabled and fusion apply gates pass (`link_method: auto_identified`). - Auto-identified links do **not** enrol voice samples. Query audio stays out of the ECAPA reference corpus until explicit enrol / promote (`auto_identified` maps to ineligible `suggestion_assisted` trust). - Operator ingest knobs `auto_name` and `auto_link` are **orthogonal** and default **false**. `identify.json` is not consent — `privacy.voice_settings.json` remains the sole voice activation authority. - Auto-identify **must** fail open: conflict, collision, ignored IDs, missing audio, or analyse errors leave `SPEAKER_*` and skip the link. Admit / import still succeeds if identify fails. - Auto-identify **must not** overwrite an effective human display name or a live profile link, auto-create a profile from a first-meeting mention, or enrol query audio. - `privacy.voice_settings.json` is the **sole** activation and consent authority. No parallel config/env enable flag may disagree with it. - Single `ActivationBarrier`: production analyse, Settings enablement, enrolment, and UI acceptance stay inaccessible until lifecycle, recovery, deletion, privacy, and integrity stages complete (`FEATURE_GATE_COMPLETE`). - Explicit bootstrap enrols trusted references; privacy opt-in alone enrols nothing. - Confirmed `speaker_profile_link` rows are **not** voice evidence. Matching compares query excerpts only to enrolled samples under `voice/samples/`, `voice/embeddings/`, and `voice/vectors/`. An empty reference corpus is expected to analyse successfully and return **no suggestion** (`NoReliableMatch`) — that is not a model failure. - Leave unlinked is session-only — not a durable rejection. - Never put raw cosine scores in immutable Phase 1 events. --- ## Layout ``` speaker_profiles_dir/ profiles/ links/ events/ operations/ # Phase 1; voice files use same journal voice/ privacy.voice_settings.json operator.voice_settings.json # enrol link cap etc. (not consent) active_generation.json generations/{model_generation_id}.json samples/{sample_id}.voice_sample.json embeddings/{embedding_id}.voice_embedding.json vectors/{embedding_id}.npy decisions/{decision_id}.voice_decision.json .cache/voice/ # disposable; wipe with voice data excerpts/ query/ suggestions/ summaries/ indexes/ .cache/identify/ # disposable fusion artefacts (not identity authority) {managed_transcript_id}.identify.v1.json ``` Path policy: reject absolute paths and `..` **before** any `stat`, read, staging, or backup (`assert_safe_relpath`). Same symlink / containment rules as Phase 1. --- ## Schema IDs (Stage 0+) | Artifact | `schema_id` | |----------|-------------| | Privacy settings | `transcriptx.voice_privacy_settings.v1` | | Operator settings | `transcriptx.voice_operator_settings.v1` | | Active generation pointer | `transcriptx.voice_active_generation.v1` | | Model generation pin | `transcriptx.voice_model_generation.v1` | | Voice sample | `transcriptx.voice_sample.v1` | | Embedding metadata | `transcriptx.voice_embedding.v1` | | Match decision | `transcriptx.voice_match_decision.v1` | | Suggestion cache | `transcriptx.voice_match_suggestion.v1` | | Profile voice summary | `transcriptx.profile_voice_summary.v1` | | Auto-identify artefact | `transcriptx.speaker_identify_artefact.v1` | Privacy notice version (user-facing copy pin): `voice_privacy_notice.v2`. Bump requires re-consent. Authoritative user-facing text: `VOICE_PRIVACY_USER_NOTICE` in `transcriptx.core.speaker_profiles.voice.privacy`. --- ## Typed link provenance `LinkProvenanceV1` (`extra=forbid`) is required on create/link/relink; optional on supersede. Methods accept this model only — never an unrestricted UI dict. `link_method`: `manual` | `suggestion_assisted` | `choose_other` | `create_new` | `relink` | `supersede` | `auto_identified`. Suggestion-assisted requires `suggestion_id` and `suggestion_digest`. `auto_identified` may carry suggestion fields when the voice channel supplied them; otherwise they stay empty. Trust mapping for `auto_identified` is the same as `suggestion_assisted` (`ineligible_trust` until promote). --- ## Auto-identify ingest Operator surfaces (Settings → Speakers, `inbox-watch --auto-name` / `--auto-link`, `python -m transcriptx.admit_originals`, `python -m transcriptx.identify_speakers`, Speaker Identification **Apply auto-identify**) share `SpeakerIdentifyService`. Guide: [`docs/runtime/auto-identify.md`](../runtime/auto-identify.md). Fusion apply table (v1): | Condition | Action | |-----------|--------| | Strong unique voice, mention absent or agreeing | apply voice name / profile | | Strong unique voice, mention disagrees on name or profile | skip (`conflict`) | | No strong voice, unique in-transcript mention | apply mention (profile id only if a unique existing profile name matches) | | No voice/mention, `style_only_apply` and strong style | apply style | | Else | skip (`abstain`) | | Two apply rows claim the same profile id or normalized display name | skip both (`collision`) | `auto_name` writes the speaker-map sidecar with mapping method `auto_identified`. `auto_link` calls `link_existing_profile` with `link_method: auto_identified` and **no** `extra_writes` (does not enrol). Mention-only first meetings may name; they **must not** create a new profile. Identify artefacts (`schema_id`: `transcriptx.speaker_identify_artefact.v1`) under `.cache/identify/` are review dumps only. Confirmed `speaker_profile_link.v1` rows and speaker-map sidecars remain the identity / display authorities. --- ## Activation barrier `ActivationBarrier.status()` / `assert_processing_allowed()`: 1. `FEATURE_GATE_COMPLETE` must be true (code constant; Stage 8 exit). 2. Privacy `enabled` and current `privacy_notice_version`. 3. `wipe_required` must be false. Until the gate opens, Settings must not offer enablement. **Current tree status:** `FEATURE_GATE_COMPLETE = True` after Stage 8 exit. Privacy still defaults to disabled — users must consent via journalled `privacy.voice_settings.json` (Settings → Storage) before analyse/enrol/accept. Local/dev exception: when that file is **absent**, `TRANSCRIPTX_VOICE_PRIVACY_DEFAULT_ENABLED=1` (set in `docker-compose.override.yml`) may treat voice as enabled. An existing settings file remains sole authority and is never overridden by the env var. Explicit bootstrap: Speakers detail → “Enrol trusted voice from confirmed links” (`VoiceBootstrapService`). Promote suggestion-assisted samples before they enter the reference corpus. Bootstrap walks confirmed links in deterministic path order up to `bootstrap_max_links` from `operator.voice_settings.json` (Settings → Storage → **Max confirmed links per voice enrol**; default **40**, range 1–200). That file is operator config only — not consent — and survives privacy revoke / evidence wipe. Match-time still caps refs per source link (`MAX_REFS_PER_SOURCE_LINK`). **Operator expectation:** enabling voice privacy (or the local privacy-default env exception) only unlocks analyse/enrol/accept. Until at least one profile has eligible enrolled embeddings, Speakers “Find voice match” / analyse will not propose a profile — check for `voice/embeddings/*.voice_embedding.json` (and matching vectors) before debugging thresholds or SpeechBrain. Settings → Speakers (when privacy is enabled) also exposes explicit library-wide batch controls: **Enrol trusted voice for all profiles** (active persisted profiles with confirmed links) and **Pre-load voice suggestions** (analyse every non-ignored managed occurrence into `.cache/voice`). Neither runs automatically on opt-in. --- ## Evidence enrolment (Stage 3) `VoiceEvidenceService.enrol_trusted_excerpts_from_link` journals samples, embeddings, vectors, and a `voice_evidence_enrolled` event (ids/counts only — no raw scores) through the **root** `OperationEngine`. Deterministic `sample_id` / `embedding_id` prevent duplicate evidence on retry. Explicit bootstrap enrol (`VoiceBootstrapService`) caps confirmed links via `bootstrap_max_links` in `operator.voice_settings.json` (default 40). Trust: `suggestion_assisted` → `ineligible_trust` until journalled promotion. Opt-in alone enrols nothing. --- ## Matching (Stages 4–5 provisional) Open-set ranking: mean of top-k ref cosines per query excerpt, then mean across queries. `tau_no_match` suppresses weak nearest neighbours. Threshold constants in `voice/thresholds.py` are **provisional** until eval freeze (`threshold_policy_id` separate from `model_generation_id`). Reference load caps duplicate evidence per source link (`MAX_REFS_PER_SOURCE_LINK`). With **zero** eligible reference embeddings, ranking has no candidates and the analyse outcome is `NoReliableMatch` (same as scores below `tau_no_match`). Enrol trusted voice from confirmed links first; only then can scores clear thresholds and surface `SuggestionAvailable`. Reject decisions suppress re-suggestion until generation or `reference_corpus_digest` changes. Leave-unlinked writes no decision. --- ## Lock protocol Snapshot under `speaker_profiles.lock` → extract/infer **outside** the lock → reacquire and revalidate privacy, active generation, link owner, fingerprint, audio identity, and corpus digest before commit or cache write. --- ## Journals Cross-domain acceptance (link/event + voice decision + retained query-evidence enrolment) is one `operation_idempotency_key` and one root journal plan via `extra_writes` / `extra_writes_builder` on Phase 1 link APIs. Accept preconditions include optional audio identity checks. `CacheInvalidationSignal` may include scope `speaker_voice`. Query excerpts enrolled on accept use trust `suggestion_assisted` / eligibility `ineligible_trust` until an explicit promote. --- ## Export / backup Ordinary exports and project backup discovery must exclude `voice/` and `.cache/voice/`. Canonical entry point: `transcriptx.core.speaker_profiles.layout.iter_paths_for_ordinary_backup` (also `voice.backup_inventory.iter_speaker_profiles_paths_for_backup` / `is_voice_excluded_relpath`). Do not ad-hoc `rglob` the profiles root for archives. Profiles and confirmed links remain. --- ## Delivery residuals (honest) Closed in the file-backed residual wave: - Accept co-journals retained query-evidence as `suggestion_assisted` / `ineligible_trust` (promote still required for corpus eligibility). - Eval harness (`scripts/eval_speaker_voice_match.py` + `voice/eval_metrics.py`) reports FAR/FRR against provisional bands; **do not invent new thresholds** without a labeled held-out library run. - Merge voice transfer uses chunked continuation journals (`voice_merge_transfer_chunk`) with content-addressed idempotency keys. - Stage 9 disposable file matrix under `.cache/voice/indexes/` is implemented and preferred when digest-fresh (see [`speaker_voice_match_index_gate.md`](../dev/speaker_voice_match_index_gate.md)). Still open / provisional: - Threshold constants in `voice/thresholds.py` remain **`voice_threshold.v1`** until an operator-labeled speaker/recording-held eval freezes `threshold_policy_id` to v2 (keep current taus until then). - Multiprocess crash-injection matrix can deepen further beyond accept+evidence, promote, and chunked merge coverage. - SQLite/DB analytics views and group gallery keyed by `profile_id` are **not** voice-file work (B5 remainder without DB is closed).