diff --git a/src/components/AI/HeyOzwell/HandsFreeChat.tsx b/src/components/AI/HeyOzwell/HandsFreeChat.tsx index 032660f89..a7d17b4a3 100644 --- a/src/components/AI/HeyOzwell/HandsFreeChat.tsx +++ b/src/components/AI/HeyOzwell/HandsFreeChat.tsx @@ -30,6 +30,8 @@ import { useHeyOzwell } from './useHeyOzwell'; import type { AISuggestedAction } from '../types'; export interface HandsFreeChatProps { + /** Isolates persisted enrollment from other users of the same browser profile. */ + voiceprintNamespace?: string; /** Chat header title. */ title?: string; /** Suggested-action chips for the empty state. */ @@ -57,6 +59,7 @@ export interface HandsFreeChatProps { /** Say "hey ozwell" to dictate, "ozwell I'm done" to send — wake + speaker-verify + dictation + AIChat. */ export function HandsFreeChat({ + voiceprintNamespace, title = 'Ozwell Assistant — hands-free', suggestions, userName, @@ -71,6 +74,7 @@ export function HandsFreeChat({ // Props flow straight through — the host (or the Storybook Controls panel) drives them live; there's no // runtime toggle UI for them, since they're deployment config, not end-user controls. const oz = useHeyOzwell({ + voiceprintNamespace, autoStart: true, // always listening while mounted autoDictateOnWake, requireDoctor, @@ -167,7 +171,10 @@ export function HandsFreeChat({ - + diff --git a/src/components/AI/HeyOzwell/SpeakerVerify/lib/speaker-verify.js b/src/components/AI/HeyOzwell/SpeakerVerify/lib/speaker-verify.js index 442f54aa0..1cd1b921f 100644 --- a/src/components/AI/HeyOzwell/SpeakerVerify/lib/speaker-verify.js +++ b/src/components/AI/HeyOzwell/SpeakerVerify/lib/speaker-verify.js @@ -1,3 +1,10 @@ +import { + clearVoiceprints, + getVoiceprints, + setVoiceprints, + voiceprintStorageKey, +} from '../../../voiceprintStore'; + /** * On-device speaker-verification gate (the "only the enrolled doctor can wake it" layer). * @@ -37,7 +44,7 @@ return "https://huggingface.co/jlocala/ozwell-voice-assets/resolve/main/sv-runtime"; })(); const MODEL = "./nemo_en_titanet_small.onnx"; // preloaded into the WASM filesystem - const LS_KEY = "ozwellDoctorVoiceprint"; // { centroid: number[], n: number } + const BASE_STORAGE_KEY = "ozwellDoctorVoiceprint"; // { centroid: number[], n: number } // Threshold from the spike's enroll-centroid distributions (genuine ~0.75 / impostor ~0.22). // 0.45 (2026-06-22): with mic DSP on + matched enrollment the genuine doctor scores ~0.75-0.93 clean, // so 0.45 passes with wide margin and rejects impostors harder. Heavy background can still drag the doctor @@ -88,57 +95,40 @@ // audio). Per-phrase because on a ~1s clip a speaker embedding still carries a little of // WHAT was said, so the doctor's "hey ozwell" centroid scores their "ozwell i'm done" // too low. We enroll + gate each phrase against its own centroid. --- - // --- WHO-centroid persistence on IndexedDB (replaces localStorage; same db/store as voiceprintStore.ts). - // verify()/enroll() read these synchronously, so we keep an in-memory `_store` hydrated from IndexedDB on - // init (before ready() resolves) and write through on save. --- - const IDB_DB = "ozwell-voice", IDB_STORE = "voiceprints"; - function idbOpen() { - return new Promise((resolve, reject) => { - const req = indexedDB.open(IDB_DB, 1); - req.onupgradeneeded = () => { if (!req.result.objectStoreNames.contains(IDB_STORE)) req.result.createObjectStore(IDB_STORE); }; - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); - } - function idbReq(mode, run) { - return idbOpen().then((db) => new Promise((resolve, reject) => { - const tx = db.transaction(IDB_STORE, mode); - const r = run(tx.objectStore(IDB_STORE)); - tx.oncomplete = () => { resolve(r && r.result); db.close(); }; - tx.onerror = () => { reject(tx.error); db.close(); }; - tx.onabort = () => { reject(tx.error); db.close(); }; // else an aborted tx leaves the promise pending - })); - } - const idbGet = (key) => idbReq("readonly", (s) => s.get(key)); - const idbPut = (key, v) => idbReq("readwrite", (s) => s.put(v, key)); - const idbDel = (key) => idbReq("readwrite", (s) => s.delete(key)); - - let _store = null; // in-memory WHO map; hydrated from IndexedDB before ready() resolves + const stores = new Map(); // in-memory WHO maps, hydrated per namespace before use + const hydrations = new Map(); // one IndexedDB read per namespace, shared by concurrent hooks // Ignore the old single-centroid format ({centroid, n}) from earlier builds. const normalizeWho = (o) => (o && Array.isArray(o.centroid)) ? {} : (o || {}); - async function hydrateStore() { + async function hydrateStore(namespace) { + const storageKey = voiceprintStorageKey(BASE_STORAGE_KEY, namespace); + if (stores.has(storageKey)) return; + const pending = hydrations.get(storageKey); + if (pending) return pending; + const hydration = getVoiceprints( + storageKey, + namespace === undefined ? (raw) => normalizeWho(JSON.parse(raw || "{}")) : undefined + ).then((value) => { + stores.set(storageKey, value || {}); + }); + hydrations.set(storageKey, hydration); try { - let v = await idbGet(LS_KEY); - if (v === undefined) { // nothing in IndexedDB — migrate a legacy localStorage value once, then drop it - let raw = null; try { raw = localStorage.getItem(LS_KEY); } catch (e) { /* ignore */ } - if (raw != null) { - v = normalizeWho(JSON.parse(raw || "{}")); - await idbPut(LS_KEY, v); - try { localStorage.removeItem(LS_KEY); } catch (e) { /* ignore */ } - } - } - _store = v || {}; - } catch (e) { _store = _store || {}; } + await hydration; + } finally { + hydrations.delete(storageKey); + } } - function loadAll() { - if (_store) return _store; + function loadAll(namespace) { + const storageKey = voiceprintStorageKey(BASE_STORAGE_KEY, namespace); + if (stores.has(storageKey)) return stores.get(storageKey); // Pre-hydration fallback (verify/enroll only run after ready(), which awaits hydrateStore). - try { return normalizeWho(JSON.parse(localStorage.getItem(LS_KEY) || "{}")); } catch (e) { return {}; } + if (namespace !== undefined) return {}; + try { return normalizeWho(JSON.parse(localStorage.getItem(BASE_STORAGE_KEY) || "{}")); } catch (e) { return {}; } } - function saveAll(obj) { - _store = obj; - idbPut(LS_KEY, obj).catch((e) => console.warn("SV save failed", e)); + function saveAll(obj, namespace) { + const storageKey = voiceprintStorageKey(BASE_STORAGE_KEY, namespace); + stores.set(storageKey, obj); + void setVoiceprints(storageKey, obj); } // Multi-VOICE, multi-condition: each phrase stores a map of voices, each with a LIST of condition // centroids. Verify passes if ANY voice's ANY condition matches (so the doctor AND an enrolled @@ -155,8 +145,8 @@ return { voices: {} }; } // Flat list of EVERY enrolled voice's centroids for a phrase (verify passes if any matches). - function allCentroids(phrase) { - const voices = normPhrase(loadAll()[phrase]).voices; + function allCentroids(phrase, namespace) { + const voices = normPhrase(loadAll(namespace)[phrase]).voices; const out = []; for (const id in voices) for (const c of (voices[id].centroids || [])) out.push(Float32Array.from(c)); return out; @@ -164,6 +154,7 @@ const SpeakerVerify = { ready: () => readyPromise, + loadNamespace: (namespace) => hydrateStore(namespace), isLoaded: () => handle !== 0, threshold: DEFAULT_THRESHOLD, // AS-norm (score normalization): normalize the raw cosine against a crowd of other voices, so the @@ -179,7 +170,7 @@ /** Enroll a VOICE for a phrase from N utterances → one condition-centroid under opts.voiceId * (default "you"). opts.append ADDS it as another condition for that voice; otherwise it replaces * that voice's conditions. opts.label names the voice. Capped to the most recent SV_CENTROID_CAP. */ - enroll(phrase, utterances /* [{samples, sampleRate}] */, opts) { + enroll(phrase, utterances /* [{samples, sampleRate}] */, opts, namespace) { if (!handle) throw new Error("SpeakerVerify not ready"); const voiceId = (opts && opts.voiceId) || DEFAULT_VOICE_ID; const embs = utterances.map(u => computeEmbedding(u.samples, u.sampleRate)); @@ -187,7 +178,7 @@ for (const e of embs) for (let i = 0; i < dim; i++) c[i] += e[i]; for (let i = 0; i < dim; i++) c[i] /= embs.length; l2normalize(c); - const all = loadAll(); + const all = loadAll(namespace); const entry = normPhrase(all[phrase]); const prevVoice = entry.voices[voiceId]; const prev = (opts && opts.append && prevVoice && Array.isArray(prevVoice.centroids)) @@ -199,13 +190,13 @@ createdAt: (prevVoice && prevVoice.createdAt) || Date.now(), centroids, }; - all[phrase] = entry; saveAll(all); + all[phrase] = entry; saveAll(all, namespace); return { n: embs.length, conditions: centroids.length, voiceId }; }, /** Verify a live utterance against the BEST condition-centroid across ALL enrolled voices. */ - verify(phrase, samples, sampleRate) { - const cents = allCentroids(phrase); + verify(phrase, samples, sampleRate, namespace) { + const cents = allCentroids(phrase, namespace); if (!cents.length) return { score: 0, znorm: null, pass: false, enrolled: false }; const live = computeEmbedding(samples, sampleRate); let score = -1; @@ -226,7 +217,7 @@ }, /** How many conditions are enrolled for a phrase, across ALL voices (0 = none). */ - conditionCount: (phrase) => allCentroids(phrase).length, + conditionCount: (phrase, namespace) => allCentroids(phrase, namespace).length, /** TitaNet speaker embedding for a raw utterance (Float32 + true sample rate). For diarization / * clustering. Returns the L2-normalized embedding, or null if the runtime isn't ready. */ @@ -237,10 +228,10 @@ /** Best-matching ENROLLED voice for a live utterance, across all voices + phrases (text-independent, * so conversational audio works). Returns { voiceId, label, score } (max cosine) or null if nothing * enrolled / not ready. The caller applies a threshold to decide whether to trust the name. */ - identify(samples, sampleRate) { + identify(samples, sampleRate, namespace) { if (!handle) return null; const live = computeEmbedding(samples, sampleRate); - const all = loadAll(); + const all = loadAll(namespace); const best = {}; // voiceId -> { label, score } for (const phrase in all) { const voices = normPhrase(all[phrase]).voices; @@ -259,8 +250,8 @@ }, /** List enrolled voices aggregated across phrases: [{ id, label, createdAt, conditions }]. */ - listVoices() { - const all = loadAll(); + listVoices(namespace) { + const all = loadAll(namespace); const acc = {}; for (const phrase in all) { const voices = normPhrase(all[phrase]).voices; @@ -280,31 +271,35 @@ /** Remove a voice across all phrases — revokes that person's WHO match (the WHAT phrase-prints, * which are shared/speaker-independent, are left in voiceprintStore). */ - removeVoice(voiceId) { - const all = loadAll(); + removeVoice(voiceId, namespace) { + const all = loadAll(namespace); let changed = false; for (const phrase in all) { const entry = normPhrase(all[phrase]); if (entry.voices[voiceId]) { delete entry.voices[voiceId]; all[phrase] = entry; changed = true; } } - if (changed) saveAll(all); + if (changed) saveAll(all, namespace); }, /** Rename a voice across all phrases. */ - renameVoice(voiceId, label) { - const all = loadAll(); + renameVoice(voiceId, label, namespace) { + const all = loadAll(namespace); let changed = false; for (const phrase in all) { const entry = normPhrase(all[phrase]); if (entry.voices[voiceId]) { entry.voices[voiceId].label = label; all[phrase] = entry; changed = true; } } - if (changed) saveAll(all); + if (changed) saveAll(all, namespace); }, // hasEnrollment(phrase) -> is that phrase enrolled; hasEnrollment() -> is anything enrolled. - hasEnrollment: (phrase) => phrase ? allCentroids(phrase).length > 0 : Object.keys(loadAll()).some((p) => allCentroids(p).length > 0), - enrolledPhrases: () => Object.keys(loadAll()), - clearEnrollment: () => { _store = {}; idbDel(LS_KEY).catch(() => {}); try { localStorage.removeItem(LS_KEY); } catch (e) { /* ignore */ } }, + hasEnrollment: (phrase, namespace) => phrase ? allCentroids(phrase, namespace).length > 0 : Object.keys(loadAll(namespace)).some((p) => allCentroids(p, namespace).length > 0), + enrolledPhrases: (namespace) => Object.keys(loadAll(namespace)), + clearEnrollment: (namespace) => { + const storageKey = voiceprintStorageKey(BASE_STORAGE_KEY, namespace); + stores.set(storageKey, {}); + void clearVoiceprints(storageKey); + }, }; async function init() { @@ -334,7 +329,7 @@ const r = await fetch(SV_DIR + "/sv-cohort.json"); if (r.ok) { cohort = (await r.json()).map((v) => Float32Array.from(v)); console.log("[SpeakerVerify] AS-norm cohort:", cohort.length); } } catch (e) { console.warn("[SpeakerVerify] cohort load failed (AS-norm off):", e); } - await hydrateStore(); // load enrolled WHO centroids from IndexedDB before reporting ready + await hydrateStore(); // preserve the existing unscoped API by default readyResolve(SpeakerVerify); } catch (e) { window.Module = prevModule; // restore the global even on failure diff --git a/src/components/AI/HeyOzwell/SpeakerVerify/useSpeakerVerify.ts b/src/components/AI/HeyOzwell/SpeakerVerify/useSpeakerVerify.ts index b848d3de1..b7981cb8f 100644 --- a/src/components/AI/HeyOzwell/SpeakerVerify/useSpeakerVerify.ts +++ b/src/components/AI/HeyOzwell/SpeakerVerify/useSpeakerVerify.ts @@ -83,40 +83,59 @@ export interface SpeakerVerifyHandle { interface SVApi { ready: () => Promise; + loadNamespace: (namespace?: string) => Promise; enroll: ( phrase: string, u: { samples: Float32Array; sampleRate: number }[], - opts?: EnrollOpts + opts?: EnrollOpts, + namespace?: string ) => { n: number; conditions: number; voiceId: string }; - verify: (phrase: string, s: Float32Array, sr: number) => VerifyResult; - conditionCount: (phrase: string) => number; + verify: ( + phrase: string, + s: Float32Array, + sr: number, + namespace?: string + ) => VerifyResult; + conditionCount: (phrase: string, namespace?: string) => number; embed: (samples: Float32Array, sampleRate: number) => Float32Array | null; - identify: (samples: Float32Array, sampleRate: number) => VoiceMatch | null; - listVoices: () => VoiceInfo[]; - removeVoice: (voiceId: string) => void; - renameVoice: (voiceId: string, label: string) => void; - clearEnrollment: () => void; - threshold: number; // raw-cosine gate (default 0.45) - znormThreshold: number; // z-score (AS-norm) gate (default 1.5) - useAsnorm: boolean; // gate on z-score instead of raw cosine + identify: ( + samples: Float32Array, + sampleRate: number, + namespace?: string + ) => VoiceMatch | null; + listVoices: (namespace?: string) => VoiceInfo[]; + removeVoice: (voiceId: string, namespace?: string) => void; + renameVoice: (voiceId: string, label: string, namespace?: string) => void; + clearEnrollment: (namespace?: string) => void; + threshold: number; + znormThreshold: number; + useAsnorm: boolean; } export interface UseSpeakerVerifyOpts { /** Set false to skip loading the ~50 MB sherpa/TitaNet runtime (e.g. when the doctor-only gate is off). * Defaults to true so existing callers are unchanged. */ enabled?: boolean; + /** Isolates persisted enrollment from other users of the same browser profile. */ + voiceprintNamespace?: string; } export function useSpeakerVerify( opts: UseSpeakerVerifyOpts = {} ): SpeakerVerifyHandle { - const { enabled = true } = opts; + const { enabled = true, voiceprintNamespace } = opts; const [ready, setReady] = React.useState(false); + const [loadedNamespace, setLoadedNamespace] = React.useState< + string | undefined + >(); const [error, setError] = React.useState(null); const svRef = React.useRef(null); React.useEffect(() => { if (!enabled) return; + setReady(false); + setError(null); + svRef.current = null; let cancelled = false; (async () => { try { @@ -126,8 +145,10 @@ export function useSpeakerVerify( .SpeakerVerify; if (!sv) throw new Error('SpeakerVerify failed to initialize'); await sv.ready(); + await sv.loadNamespace(voiceprintNamespace); if (cancelled) return; svRef.current = sv; + setLoadedNamespace(voiceprintNamespace); console.log('[speaker] TitaNet ready'); setReady(true); } catch (e) { @@ -137,26 +158,35 @@ export function useSpeakerVerify( return () => { cancelled = true; }; - }, [enabled]); + }, [enabled, voiceprintNamespace]); + + const scopedApi = + enabled && ready && loadedNamespace === voiceprintNamespace + ? svRef.current + : null; return { - ready, + ready: scopedApi !== null, error, enroll: (phrase, utterances, opts) => - svRef.current?.enroll(phrase, utterances, opts) ?? null, + scopedApi?.enroll(phrase, utterances, opts, voiceprintNamespace) ?? null, verify: (phrase, samples, sampleRate) => - svRef.current?.verify(phrase, samples, sampleRate) ?? null, - conditionCount: (phrase) => svRef.current?.conditionCount(phrase) ?? 0, + scopedApi?.verify(phrase, samples, sampleRate, voiceprintNamespace) ?? + null, + conditionCount: (phrase) => + scopedApi?.conditionCount(phrase, voiceprintNamespace) ?? 0, embed: (samples, sampleRate) => - svRef.current?.embed(samples, sampleRate) ?? null, + scopedApi?.embed(samples, sampleRate) ?? null, identify: (samples, sampleRate) => - svRef.current?.identify(samples, sampleRate) ?? null, - listVoices: () => svRef.current?.listVoices() ?? [], - removeVoice: (voiceId) => svRef.current?.removeVoice(voiceId), - renameVoice: (voiceId, label) => svRef.current?.renameVoice(voiceId, label), - clear: () => svRef.current?.clearEnrollment(), + scopedApi?.identify(samples, sampleRate, voiceprintNamespace) ?? null, + listVoices: () => scopedApi?.listVoices(voiceprintNamespace) ?? [], + removeVoice: (voiceId) => + scopedApi?.removeVoice(voiceId, voiceprintNamespace), + renameVoice: (voiceId, label) => + scopedApi?.renameVoice(voiceId, label, voiceprintNamespace), + clear: () => scopedApi?.clearEnrollment(voiceprintNamespace), setGates: (g) => { - const sv = svRef.current; + const sv = scopedApi; if (!sv) return; if (g.cosine != null) sv.threshold = g.cosine; if (g.znorm != null) sv.znormThreshold = g.znorm; diff --git a/src/components/AI/HeyOzwell/VoiceManager.tsx b/src/components/AI/HeyOzwell/VoiceManager.tsx index e3f281ea0..fb2ff7cad 100644 --- a/src/components/AI/HeyOzwell/VoiceManager.tsx +++ b/src/components/AI/HeyOzwell/VoiceManager.tsx @@ -30,6 +30,8 @@ const dangerOutline = 'border-destructive text-destructive hover:bg-destructive/10 hover:text-destructive'; export interface VoiceManagerProps { + /** Isolates persisted enrollment from other users of the same browser profile. */ + voiceprintNamespace?: string; /** Octopus logo source, forwarded to the enrollment screen. */ logoSrc?: string; } @@ -41,8 +43,11 @@ interface SetupTarget { } /** The central voice-enrollment management page. */ -export function VoiceManager({ logoSrc }: VoiceManagerProps) { - const sv = useSpeakerVerify(); +export function VoiceManager({ + logoSrc, + voiceprintNamespace, +}: VoiceManagerProps) { + const sv = useSpeakerVerify({ voiceprintNamespace }); const [inSetup, setInSetup] = React.useState(null); const [, setTick] = React.useState(0); // bump to force a re-read of the voice list after enroll/remove/clear const [addName, setAddName] = React.useState(''); @@ -60,6 +65,7 @@ export function VoiceManager({ logoSrc }: VoiceManagerProps) { if (inSetup) { return ( { sv.clear(); - void clearWhatPrints(); + void clearWhatPrints(voiceprintNamespace); setTick((t) => t + 1); }; diff --git a/src/components/AI/HeyOzwell/VoiceSetup.tsx b/src/components/AI/HeyOzwell/VoiceSetup.tsx index c700aab45..1f8787993 100644 --- a/src/components/AI/HeyOzwell/VoiceSetup.tsx +++ b/src/components/AI/HeyOzwell/VoiceSetup.tsx @@ -24,6 +24,8 @@ const ozBtn = 'bg-ozwell hover:bg-ozwell active:bg-ozwell text-ozwell-foreground hover:brightness-95 active:brightness-90'; export interface VoiceSetupProps { + /** Isolates persisted enrollment from other users of the same browser profile. */ + voiceprintNamespace?: string; /** * 'enroll' (default) = fresh first-time setup. 'add' = jump straight into appending a new voice/condition * to the existing voiceprints — what the settings menu's "Add a voice" uses, so the user doesn't have to @@ -44,6 +46,7 @@ export interface VoiceSetupProps { /** On-device voice enrollment — tap the octopus, it pulses as you talk. Brand-aligned. */ export function VoiceSetup({ + voiceprintNamespace, mode = 'enroll', voiceId, label, @@ -51,7 +54,12 @@ export function VoiceSetup({ onDone, onCancel, }: VoiceSetupProps) { - const oz = useVoiceSetup({ startAdding: mode === 'add', voiceId, label }); + const oz = useVoiceSetup({ + startAdding: mode === 'add', + voiceId, + label, + voiceprintNamespace, + }); const { phase, phrase, step, total, adding, level, ready, error } = oz; const octoScale = 1 + Math.min(0.32, level * 2.2); diff --git a/src/components/AI/HeyOzwell/useHeyOzwell.ts b/src/components/AI/HeyOzwell/useHeyOzwell.ts index 36de40ea4..601491bc2 100644 --- a/src/components/AI/HeyOzwell/useHeyOzwell.ts +++ b/src/components/AI/HeyOzwell/useHeyOzwell.ts @@ -60,6 +60,8 @@ function stopTrimLeadIn(): number { } export interface UseHeyOzwellOptions { + /** Isolates persisted enrollment from other users of the same browser profile. */ + voiceprintNamespace?: string; /** ON: "hey ozwell" opens the chat AND starts dictating. OFF: it just opens the chat and waits. */ autoDictateOnWake?: boolean; /** Close the chat popup after "ozwell I'm done" transcribes + sends. */ @@ -243,6 +245,7 @@ export function useHeyOzwell( options: UseHeyOzwellOptions = {} ): UseHeyOzwellResult { const { + voiceprintNamespace, autoDictateOnWake = false, closeChatOnDone = false, transcription = 'browser', @@ -314,7 +317,7 @@ export function useHeyOzwell( // Doctor-only gate (loads the ~50 MB speaker runtime only when requireDoctor). A rolling recorder is // the 2nd consumer of the shared stream, giving the WHO check the wake-utterance audio. - const sv = useSpeakerVerify({ enabled: requireDoctor }); + const sv = useSpeakerVerify({ enabled: requireDoctor, voiceprintNamespace }); const svRef = React.useRef(sv); svRef.current = sv; const rollRef = React.useRef(null); @@ -595,7 +598,9 @@ export function useHeyOzwell( if (!requireDoctor || !active || !wake.ready) return; let cancelled = false; let tries = 0; - void loadWhatPrints().then((loaded) => { + wakeRef.current?.setVoiceprint('hey-ozwell', []); + wakeRef.current?.setVoiceprint("ozwell-i'm-done", []); + void loadWhatPrints(voiceprintNamespace).then((loaded) => { if (cancelled) return; for (const k in loaded) wakeRef.current?.setVoiceprint(k, loaded[k]); }); @@ -608,10 +613,12 @@ export function useHeyOzwell( tryOpen(); return () => { cancelled = true; + wakeRef.current?.setVoiceprint('hey-ozwell', []); + wakeRef.current?.setVoiceprint("ozwell-i'm-done", []); rollRef.current?.close(); rollRef.current = null; }; - }, [requireDoctor, active, wake.ready]); + }, [requireDoctor, active, wake.ready, voiceprintNamespace]); // Header octopus load state, split into two rings so the slow transcription warm-up never makes // the octopus look unavailable (primary ring = wake pre-warm; secondary arc = transcription). diff --git a/src/components/AI/HeyOzwell/useVoiceSetup.ts b/src/components/AI/HeyOzwell/useVoiceSetup.ts index 7ff93751e..651a768ae 100644 --- a/src/components/AI/HeyOzwell/useVoiceSetup.ts +++ b/src/components/AI/HeyOzwell/useVoiceSetup.ts @@ -34,6 +34,8 @@ const VP_CAP = 18; const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); // global setTimeout — safe to import in SSR/Node export interface UseVoiceSetupOptions { + /** Isolates persisted enrollment from other users of the same browser profile. */ + voiceprintNamespace?: string; /** Start directly in "add a voice" (append) mode instead of a fresh enroll — for the settings menu's * "Add a voice", which appends another authorized voice / condition to the existing voiceprints. */ startAdding?: boolean; @@ -70,7 +72,7 @@ export interface UseVoiceSetupResult { export function useVoiceSetup( options: UseVoiceSetupOptions = {} ): UseVoiceSetupResult { - const { startAdding = false, voiceId, label } = options; + const { startAdding = false, voiceId, label, voiceprintNamespace } = options; // Resolve the voice id once: the caller's, else "you" for a fresh enroll, else a fresh id (add mode). const [enrollVoiceId] = React.useState( () => @@ -79,7 +81,7 @@ export function useVoiceSetup( ? `voice-${Date.now()}-${Math.floor(Math.random() * 1e4)}` : 'you') ); - const sv = useSpeakerVerify(); + const sv = useSpeakerVerify({ voiceprintNamespace }); const expectRef = React.useRef(null); const resolveRef = React.useRef<((n: string) => void) | null>(null); const whatRef = React.useRef>({}); @@ -106,10 +108,15 @@ export function useVoiceSetup( // persisted WHAT templates so "Add another spot" appends across reloads. React.useEffect(() => { warmWhisper(); - void loadWhatPrints().then((w) => { - whatRef.current = w; + let cancelled = false; + whatRef.current = {}; + void loadWhatPrints(voiceprintNamespace).then((w) => { + if (!cancelled) whatRef.current = w; }); - }, []); + return () => { + cancelled = true; + }; + }, [voiceprintNamespace]); // Rolling recorder (for the enrollment clips) + a room-volume analyser for the octopus pulse — both as // second consumers of the detector's shared stream (no extra getUserMedia). @@ -236,10 +243,10 @@ export function useVoiceSetup( whatRef.current[ph.key] = merged; wakeRef.current.setVoiceprint(ph.key, merged); } - void saveWhatPrints(whatRef.current); + void saveWhatPrints(whatRef.current, voiceprintNamespace); setAdding(false); setPhase('done'); - }, [bothReady, phase, adding, sv, enrollVoiceId, label]); + }, [bothReady, phase, adding, sv, enrollVoiceId, label, voiceprintNamespace]); const addAnotherSpot = React.useCallback(() => { setAdding(true); diff --git a/src/components/AI/voiceprintStore.test.ts b/src/components/AI/voiceprintStore.test.ts new file mode 100644 index 000000000..dd2ecbcda --- /dev/null +++ b/src/components/AI/voiceprintStore.test.ts @@ -0,0 +1,81 @@ +import 'fake-indexeddb/auto'; + +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + clearWhatPrints, + clearVoiceprints, + getVoiceprints, + loadWhatPrints, + saveWhatPrints, + setVoiceprints, + voiceprintStorageKey, +} from './voiceprintStore'; + +const prints = (value: number) => ({ + 'hey-ozwell': [Float32Array.from([value])], +}); + +describe('voiceprintStore namespaces', () => { + beforeEach(async () => { + localStorage.clear(); + await new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase('ozwell-voice'); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + request.onblocked = () => reject(new Error('Database deletion blocked')); + }); + }); + + it('preserves the existing key when no namespace is provided', () => { + expect(voiceprintStorageKey('voiceprints')).toBe('voiceprints'); + }); + + it('rejects an empty namespace', () => { + expect(() => voiceprintStorageKey('voiceprints', ' ')).toThrow( + 'Voiceprint namespace must not be empty' + ); + }); + + it('isolates WHAT prints by namespace', async () => { + await saveWhatPrints(prints(1), 'user-a'); + await saveWhatPrints(prints(2), 'user-b'); + + expect((await loadWhatPrints('user-a'))['hey-ozwell'][0][0]).toBe(1); + expect((await loadWhatPrints('user-b'))['hey-ozwell'][0][0]).toBe(2); + expect(await loadWhatPrints()).toEqual({}); + }); + + it('clears only the selected namespace', async () => { + await saveWhatPrints(prints(1), 'user-a'); + await saveWhatPrints(prints(2), 'user-b'); + + await clearWhatPrints('user-a'); + + expect(await loadWhatPrints('user-a')).toEqual({}); + expect((await loadWhatPrints('user-b'))['hey-ozwell'][0][0]).toBe(2); + }); + + it('isolates WHO records through the shared storage key', async () => { + const userAKey = voiceprintStorageKey('ozwellDoctorVoiceprint', 'user-a'); + const userBKey = voiceprintStorageKey('ozwellDoctorVoiceprint', 'user-b'); + await setVoiceprints(userAKey, { owner: 'a' }); + await setVoiceprints(userBKey, { owner: 'b' }); + + await clearVoiceprints(userAKey); + + expect(await getVoiceprints(userAKey)).toBeUndefined(); + expect(await getVoiceprints(userBKey)).toEqual({ owner: 'b' }); + }); + + it('does not assign unscoped legacy prints to a namespace', async () => { + localStorage.setItem( + 'ozwellWhatPrints', + JSON.stringify({ 'hey-ozwell': [[3]] }) + ); + + expect(await loadWhatPrints('user-a')).toEqual({}); + expect(localStorage.getItem('ozwellWhatPrints')).not.toBeNull(); + expect((await loadWhatPrints())['hey-ozwell'][0][0]).toBe(3); + }); +}); diff --git a/src/components/AI/voiceprintStore.ts b/src/components/AI/voiceprintStore.ts index a495f25a3..e03db4756 100644 --- a/src/components/AI/voiceprintStore.ts +++ b/src/components/AI/voiceprintStore.ts @@ -15,6 +15,13 @@ const DB_NAME = 'ozwell-voice'; const STORE = 'voiceprints'; const DB_VERSION = 1; +export function voiceprintStorageKey(key: string, namespace?: string): string { + if (namespace === undefined) return key; + if (namespace.trim().length === 0) + throw new Error('Voiceprint namespace must not be empty'); + return `${key}:${encodeURIComponent(namespace)}`; +} + function openDb(): Promise { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, DB_VERSION); @@ -194,19 +201,22 @@ function parseLegacyWhat(raw: string): Record { return out; } -export function loadWhatPrints(): Promise> { +export function loadWhatPrints( + namespace?: string +): Promise> { return getVoiceprints>( - WHAT_KEY, - parseLegacyWhat + voiceprintStorageKey(WHAT_KEY, namespace), + namespace === undefined ? parseLegacyWhat : undefined ).then((v) => v ?? {}); } export function saveWhatPrints( - map: Record + map: Record, + namespace?: string ): Promise { - return setVoiceprints(WHAT_KEY, map); + return setVoiceprints(voiceprintStorageKey(WHAT_KEY, namespace), map); } -export function clearWhatPrints(): Promise { - return clearVoiceprints(WHAT_KEY); +export function clearWhatPrints(namespace?: string): Promise { + return clearVoiceprints(voiceprintStorageKey(WHAT_KEY, namespace)); }