UNPKG

react-native-wakeword

Version:

Voice/Wake-word detection library for React Native

316 lines (258 loc) 10.1 kB
// 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);