react-native-wakeword
Version:
Voice/Wake-word detection library for React Native
316 lines (258 loc) • 10.1 kB
JavaScript
// wakewords/SpeakerVerificationRNBridge.js
import { NativeModules, NativeEventEmitter, Platform } from 'react-native';
import resolveAssetSource from 'react-native/Libraries/Image/resolveAssetSource';
// Keep using the SAME native module (KeyWordRNBridge.m already exports these methods)
const { KeyWordRNBridge } = NativeModules;
const emitter = KeyWordRNBridge ? new NativeEventEmitter(KeyWordRNBridge) : null;
const VERBOSE = true;
const PFX = '[SVJS]';
const ts = () => new Date().toISOString();
function dbg(...args) { if (VERBOSE) console.log(ts(), PFX, ...args); }
function dbgErr(...args) { console.log(ts(), PFX, '❌', ...args); }
function stripFileScheme(uri) {
if (!uri) return uri;
return uri.startsWith('file://') ? uri.replace('file://', '') : uri;
}
// Accepts:
// - string (absolute path OR "name.ext")
// - require('...') (number) -> resolved to uri/path
function normalizePathOrName(input) {
if (input == null) return input;
// require(...) returns a number in RN
if (typeof input === 'number') {
const src = resolveAssetSource(input);
// src.uri might be:
// - "file:///.../name.onnx" (iOS)
// - "asset:/name.onnx" or "assets:/..." (Android)
// We pass through; native iOS resolver handles bundle names too.
if (src?.uri) return stripFileScheme(src.uri);
return input;
}
if (typeof input === 'string') {
return stripFileScheme(input);
}
// if someone passes an object accidentally, just stringify it
return String(input);
}
function assertMethod(name) {
if (!KeyWordRNBridge?.[name]) {
throw new Error(`KeyWordRNBridge.${name} is not available (native not linked / iOS only?)`);
}
}
export class SpeakerVerificationRNBridgeInstance {
engineId;
constructor(engineId) {
this.engineId = engineId;
}
/**
* Create the native speaker verifier engine.
*
* modelPathOrName:
* - absolute path OR "speaker_model.dm" in the iOS app bundle
* - OR require('./assets/speaker_model.dm')
*
* enrollmentJsonPathOrName:
* - absolute path OR "enrollment.json" in the iOS app bundle
* - OR require('./assets/enrollment.json')
*
* options (optional):
* { decisionThreshold, frameSize, tailSeconds, maxTailSeconds, cmn, expectedLayoutBDT, logLevel }
*/
async create(modelPathOrName, enrollmentJsonPathOrName, options = {}) {
assertMethod('createSpeakerVerifier');
const modelArg = normalizePathOrName(modelPathOrName);
const jsonArg = normalizePathOrName(enrollmentJsonPathOrName);
dbg('createSpeakerVerifier args:',
{ engineId: this.engineId, modelArg, jsonArg, options });
return await KeyWordRNBridge.createSpeakerVerifier(
this.engineId,
modelArg,
jsonArg,
options || {}
);
}
/**
* Verify WAV by streaming in native.
*
* wavPathOrName:
* - absolute path OR "test.wav" in bundle
* - OR require('./assets/test.wav')
*
* resetState:
* - if true, clears internal streaming state before verification
*/
async verifyWavStreaming(wavPathOrName, resetState = true) {
assertMethod('verifySpeakerWavStreaming');
const wavArg = normalizePathOrName(wavPathOrName);
dbg('verifySpeakerWavStreaming args:', { engineId: this.engineId, wavArg, resetState: !!resetState });
return await KeyWordRNBridge.verifySpeakerWavStreaming(
this.engineId,
wavArg,
!!resetState
);
}
async destroy() {
assertMethod('destroySpeakerVerifier');
return await KeyWordRNBridge.destroySpeakerVerifier(this.engineId);
}
}
// Convenience creator (parity with wakeword side)
export const createSpeakerVerificationInstance = async (engineId) => {
return new SpeakerVerificationRNBridgeInstance(engineId);
};
// ============================================================
// MARK: - Speaker Verification Mic Controller (Swift) - RN APIs
// ============================================================
export class SpeakerVerificationMicController {
controllerId;
constructor(controllerId) {
this.controllerId = controllerId;
}
async create(configJson) {
assertMethod('createSpeakerVerificationMicController');
// IMPORTANT: must pass a REAL JSON string, not "[object Object]"
const jsonStr = (typeof configJson === 'string')
? configJson
: JSON.stringify(configJson ?? {});
dbg('createSpeakerVerificationMicController args:',
{ controllerId: this.controllerId, jsonStrLen: jsonStr.length, jsonStr });
return await KeyWordRNBridge.createSpeakerVerificationMicController(this.controllerId, jsonStr);
}
async beginOnboarding(enrollmentId, targetEmbeddingCount, reset = true) {
assertMethod('svBeginOnboarding');
dbg('svBeginOnboarding args:', { controllerId: this.controllerId, enrollmentId, targetEmbeddingCount, reset: !!reset });
return await KeyWordRNBridge.svBeginOnboarding(
this.controllerId,
String(enrollmentId ?? ''),
Number(targetEmbeddingCount ?? 0),
!!reset
);
}
async getNextEmbeddingFromMic() {
assertMethod('svGetNextEmbeddingFromMic');
dbg('svGetNextEmbeddingFromMic args:', { controllerId: this.controllerId });
return await KeyWordRNBridge.svGetNextEmbeddingFromMic(this.controllerId);
}
async finalizeOnboardingNow() {
assertMethod('svFinalizeOnboardingNow');
dbg('svFinalizeOnboardingNow args:', { controllerId: this.controllerId });
return await KeyWordRNBridge.svFinalizeOnboardingNow(this.controllerId);
}
async setEnrollmentJson(enrollmentJson) {
assertMethod('svSetEnrollmentJson');
dbg('svSetEnrollmentJson args:', { controllerId: this.controllerId, len: String(enrollmentJson ?? '').length });
// if caller passes object, stringify it (native expects JSON string)
const s =
typeof enrollmentJson === 'string'
? enrollmentJson
: JSON.stringify(enrollmentJson ?? {});
return await KeyWordRNBridge.svSetEnrollmentJson(
this.controllerId,
s
);
}
async startVerifyFromMic(resetState = true) {
assertMethod('svStartVerifyFromMic');
dbg('svStartVerifyFromMic args:', { controllerId: this.controllerId, resetState: !!resetState });
return await KeyWordRNBridge.svStartVerifyFromMic(
this.controllerId,
!!resetState
);
}
async startEndlessVerifyFromMic(hopSeconds = 0.5, stopOnMatch = false, resetState = true) {
assertMethod('svStartEndlessVerifyFromMic');
dbg('svStartEndlessVerifyFromMic args:', { controllerId: this.controllerId, hopSeconds, stopOnMatch, resetState: !!resetState });
if (Platform.OS === 'android') {
return await KeyWordRNBridge.svStartEndlessVerifyFromMic(
this.controllerId,
Number(hopSeconds),
!!stopOnMatch,
!!resetState
);
}
// iOS bridge currently expects 3 JS args (controllerId, hopSeconds, stopOnMatch)
return await KeyWordRNBridge.svStartEndlessVerifyFromMic(
this.controllerId,
Number(hopSeconds),
!!stopOnMatch
);
}
async stop() {
assertMethod('svStopMic');
return await KeyWordRNBridge.svStopMic(this.controllerId);
}
async destroy() {
assertMethod('destroySpeakerVerificationMicController');
return await KeyWordRNBridge.destroySpeakerVerificationMicController(this.controllerId);
}
}
export const createSpeakerVerificationMicController = async (controllerId) => {
return new SpeakerVerificationMicController(controllerId);
};
async function verifyFromMicWithEnrollment(enrollmentJson, setUiMessage) {
const micConfig = {
modelPath: 'speaker_model.dm',
options: {
decisionThreshold: 0.35,
tailSeconds: 2.0,
frameSize: 1280,
maxTailSeconds: 3.0,
cmn: true,
expectedLayoutBDT: false,
},
};
const ctrl = await createSpeakerVerificationMicController('svMicVerify1');
await ctrl.create(JSON.stringify(micConfig));
try {
// IMPORTANT: set enrollment BEFORE starting verify
await ctrl.setEnrollmentJson(enrollmentJson);
const waitVerify = (timeoutMs = 60_000) =>
new Promise<any>((resolve, reject) => {
const offR = onSpeakerVerificationVerifyResult((e) => {
if (e?.controllerId !== 'svMicVerify1') return;
offR?.();
offE?.();
resolve(e);
});
const offE = onSpeakerVerificationError((e) => {
if (e?.controllerId !== 'svMicVerify1') return;
offR?.();
offE?.();
reject(new Error(`[SVJS] verify error: ${JSON.stringify(e)}`));
});
setTimeout(() => {
offR?.();
offE?.();
reject(new Error('NO_SPEECH_TIMEOUT'));
}, timeoutMs);
});
// start one verify pass (native should emit onSpeakerVerificationVerifyResult)
setUiMessage?.('🎙️ Mic verify: please speak (up to 60s)...');
await ctrl.startVerifyFromMic(true);
const res = await waitVerify(60_000);
console.log('[SVJS] mic verify result event:', res);
setUiMessage?.(`✅ Mic verify done → score=${res?.bestScore ?? res?.score ?? 'n/a'}`);
return res;
} finally {
await ctrl.stop().catch(() => {});
await ctrl.destroy().catch(() => {});
}
}
// ============================================================
// MARK: - Event helpers
// ============================================================
function onEvent(eventName, cb) {
if (!emitter) {
throw new Error('NativeEventEmitter unavailable: KeyWordRNBridge is not linked.');
}
const sub = emitter.addListener(eventName, cb);
return () => sub.remove();
}
export const onSpeakerVerificationOnboardingProgress = (cb) =>
onEvent('onSpeakerVerificationOnboardingProgress', cb);
export const onSpeakerVerificationOnboardingDone = (cb) =>
onEvent('onSpeakerVerificationOnboardingDone', cb);
export const onSpeakerVerificationVerifyResult = (cb) =>
onEvent('onSpeakerVerificationVerifyResult', cb);
export const onSpeakerVerificationError = (cb) =>
onEvent('onSpeakerVerificationError', cb);