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