UNPKG

react-native-davoice-tts

Version:

tts library for React Native

744 lines (675 loc) 28.5 kB
// speech/index.ts import { NativeModules, NativeEventEmitter, DeviceEventEmitter, Platform } from 'react-native'; import resolveAssetSource from 'react-native/Libraries/Image/resolveAssetSource'; // -------------------- VERBOSE LOGGING -------------------- const VERBOSE = true; const PFX = '[SpeechJS]'; function ts() { const d = new Date(); return `${d.toISOString()}`; } function dbg(...args: any[]) { if (!VERBOSE) return; // eslint-disable-next-line no-console console.log(ts(), PFX, ...args); } function dbgErr(...args: any[]) { // eslint-disable-next-line no-console console.log(ts(), PFX, '❌', ...args); } function safeJson(x: any) { try { return JSON.stringify(x); } catch { return String(x); } } // If you use typed-array -> base64, Buffer is convenient (works in RN) let toBase64: (u8: Uint8Array) => string; try { // eslint-disable-next-line @typescript-eslint/no-var-requires const { Buffer } = require('buffer'); toBase64 = (u8) => Buffer.from(u8.buffer, u8.byteOffset, u8.byteLength).toString('base64'); } catch { // very rare fallback toBase64 = (u8) => globalThis.btoa(String.fromCharCode(...u8)); } // Native handles const NativeSpeech = NativeModules.SpeechBridge; // iOS unified (if present) const NativeSTT = NativeModules.STT || NativeModules.RCTSTT || NativeModules.Voice || NativeModules.RCTVoice; const NativeTTS = NativeModules.DaVoiceTTSBridge; // Print what we actually have at module load dbg('Platform=', Platform.OS); dbg('NativeSpeech (SpeechBridge) exists?', !!NativeSpeech, 'keys=', NativeSpeech ? Object.keys(NativeSpeech) : null); dbg('NativeSTT exists?', !!NativeSTT, 'keys=', NativeSTT ? Object.keys(NativeSTT) : null); dbg('NativeTTS (DaVoiceTTSBridge) exists?', !!NativeTTS, 'keys=', NativeTTS ? Object.keys(NativeTTS) : null); // ---- Types ---- export type SpeechStartEvent = {}; export type SpeechEndEvent = {}; export type SpeechRecognizedEvent = { isFinal: boolean }; export type SpeechErrorEvent = { error: { code?: string; message?: string } }; export type SpeechResultsEvent = { value: string[] }; export type SpeechVolumeChangeEvent = { value: number }; export type NewSpeechWAVEvent = { path: string }; // Allow passing require() asset, uri string, etc. export type RNAssetLike = any; // keep permissive (Metro asset numbers/objects vary) export type ModelRef = string | RNAssetLike; export type UnifiedEvents = { // STT onSpeechStart?: (e: SpeechStartEvent) => void; onSpeechRecognized?: (e: SpeechRecognizedEvent) => void; onSpeechEnd?: (e: SpeechEndEvent) => void; onSpeechError?: (e: SpeechErrorEvent) => void; onSpeechResults?: (e: SpeechResultsEvent) => void; onSpeechPartialResults?: (e: SpeechResultsEvent) => void; onSpeechVolumeChanged?: (e: SpeechVolumeChangeEvent) => void; /** Android-only: emitted when native MIC+VAD saves a full utterance WAV */ onNewSpeechWAV?: (e: NewSpeechWAVEvent) => void; // TTS onFinishedSpeaking?: () => void; }; type NativeEventName = | 'onSpeechStart' | 'onSpeechRecognized' | 'onSpeechEnd' | 'onSpeechError' | 'onSpeechResults' | 'onSpeechPartialResults' | 'onSpeechVolumeChanged' | 'onNewSpeechWAV' | 'onFinishedSpeaking'; // --- NEW: descriptor for external PCM payloads --- export type ExternalPCM = { /** base64 of raw PCM payload */ base64: string; /** sample rate of payload (e.g., 16000, 22050, 24000, 44100, 48000) */ sampleRate: number; /** number of channels in payload (default 1) */ channels?: number; /** whether payload is interleaved (default true) */ interleaved?: boolean; /** 'i16' for 16-bit signed integer, 'f32' for 32-bit float */ format: 'i16' | 'f32'; /** whether this item should trigger onFinishedSpeaking when done (default true) */ markAsLast?: boolean; }; class Speech { // ---- MIN: serialize TTS + wait-for-finished ---- private ttsChain: Promise<void> = Promise.resolve(); private ttsPendingResolve: (() => void) | null = null; private ttsPendingTimeout: any = null; private _onNativeFinishedSpeaking() { dbg('[EVENT onFinishedSpeaking]'); // 1) let app callback run try { this.handlers.onFinishedSpeaking(); } catch (e) { dbgErr('onFinishedSpeaking handler error', String(e)); } // 2) resolve the internal await (if any) if (this.ttsPendingTimeout) { clearTimeout(this.ttsPendingTimeout); this.ttsPendingTimeout = null; } const r = this.ttsPendingResolve; this.ttsPendingResolve = null; if (r) r(); } private _nativeSpeak(text: string, speakerId: number, s: number) { if (Platform.OS === 'ios' && NativeSpeech?.speak) return (NativeSpeech as any).speak(text, speakerId, s); if (!NativeTTS?.speak) throw new Error('TTS speak not available'); return (NativeTTS as any).speak(text, speakerId, s); } private _speakAndWait(text: string, speakerId: number, s: number, timeoutMs = 60000) { return new Promise<void>((resolve, reject) => { this.ttsPendingResolve = resolve; // safety: never hang forever this.ttsPendingTimeout = setTimeout(() => { this.ttsPendingResolve = null; reject(new Error('TTS timeout waiting for onFinishedSpeaking')); }, timeoutMs); try { this._nativeSpeak(text, speakerId, s); } catch (e) { if (this.ttsPendingTimeout) { clearTimeout(this.ttsPendingTimeout); this.ttsPendingTimeout = null; } this.ttsPendingResolve = null; reject(e as any); } }); } private sttEmitter: NativeEventEmitter | null = null; private ttsEmitter: NativeEventEmitter | typeof DeviceEventEmitter | null = null; private unifiedEmitter: NativeEventEmitter | null = null; private subs: Array<{ remove: () => void }> = []; private handlers: Required<UnifiedEvents>; // top of file (new state) private lastLocale: string | null = null; private lastModel: string | null = null; private iosTtsOnly = false; // when true, use NativeTTS directly on iOS private logCall(name: string, payload?: any) { dbg(`[CALL ${name}]`, payload !== undefined ? safeJson(payload) : ''); } // ✅ NEW: resolve require() assets to a usable URI/path string private resolveModelToPath(model: ModelRef): string { // ✅ Backward compatible: plain strings are passed through unchanged if (typeof model === 'string') return model; try { const asset = resolveAssetSource(model); dbg('[resolveModelToPath] resolveAssetSource ->', asset); const uri = asset?.uri; if (uri) return String(uri); } catch { // ignore and fall through } return typeof model === 'string' ? model : String(model); } constructor() { this.handlers = { onSpeechStart: () => {}, onSpeechRecognized: () => {}, onSpeechEnd: () => {}, onSpeechError: () => {}, onSpeechResults: () => {}, onSpeechPartialResults: () => {}, onSpeechVolumeChanged: () => {}, onNewSpeechWAV: () => {}, onFinishedSpeaking: () => {}, }; // Emitters per-platform if (Platform.OS !== 'web') { if (Platform.OS === 'ios' && NativeSpeech) { this.unifiedEmitter = new NativeEventEmitter(NativeSpeech); dbg('[constructor] iOS unifiedEmitter created'); } else { // Android (and iOS fallback): separate modules if (NativeSTT) { this.sttEmitter = new NativeEventEmitter(NativeSTT); dbg('[constructor] sttEmitter created'); } // ANDROID: Native module emits through DeviceEventEmitter if (Platform.OS === 'android') { this.ttsEmitter = DeviceEventEmitter; dbg('[constructor] android ttsEmitter=DeviceEventEmitter'); } else { // non-unified iOS fallback (if ever used) if (NativeTTS) { this.ttsEmitter = new NativeEventEmitter(NativeTTS); dbg('[constructor] iOS fallback ttsEmitter created'); } } } } } // NEW: tiny helper to (re)wire listeners depending on mode private rewireListenersForMode() { this.teardownListeners(); // if iOS unified + NOT tts-only -> use unified emitter if (Platform.OS === 'ios' && NativeSpeech && !this.iosTtsOnly) { this.unifiedEmitter = new NativeEventEmitter(NativeSpeech); // unified handles both STT + TTS events } else { // fallback: separate emitters if (NativeSTT) this.sttEmitter = new NativeEventEmitter(NativeSTT); if (Platform.OS === 'android') this.ttsEmitter = DeviceEventEmitter; else if (NativeTTS) this.ttsEmitter = new NativeEventEmitter(NativeTTS); } this.ensureListeners(); } // ---------- Init / Destroy ---------- /** * ANDROID ONLY: Initialize remote capture (MIC + VAD) that saves utterances to WAV * and emits 'onNewSpeechWAV' with { path }. No-op on iOS (throws). */ async initAllRemoteSTT(model: ModelRef): Promise<void> { if (Platform.OS !== 'android') { throw new Error('initAllRemoteSTT is Android-only.'); } if (!NativeSTT?.startRemoteSpeech) { throw new Error('Native STT module missing startRemoteSpeech()'); } this.ensureListeners(); const modelPath = this.resolveModelToPath(model); await new Promise<void>((resolve, reject) => { try { NativeSTT.startRemoteSpeech((err: string) => (err ? reject(new Error(err)) : resolve())); } catch (e) { reject(e as any); } }); // Fallback (Android or iOS w/o SpeechBridge): // 1) Start STT (engine hot will happen internally); 2) init TTS. if (!NativeSTT || !NativeTTS) { throw new Error('Missing native bridges (STT/TTS).'); } // Init TTS await NativeTTS.initTTS({ model: modelPath }); } dbgModel(label: string, model: any) { try { console.log(`[MODELDBG] ${label} typeof=`, typeof model, ' value=', model); try { const asset = resolveAssetSource(model); console.log(`[MODELDBG] ${label} resolveAssetSource=`, asset); if (asset?.uri) console.log(`[MODELDBG] ${label} asset.uri=`, asset.uri); } catch (e) { console.log(`[MODELDBG] ${label} resolveAssetSource threw:`, String(e)); } } catch {} } // ---------- Init / Destroy ---------- /** * iOS: initialize STT then TTS via native SpeechBridge if available. * Android: no special init needed; optionally preload TTS (if you want). */ async initAll(opts: { locale: string; model: ModelRef; timeoutMs?: number }) { this.dbgModel('initAll.opts.model (raw)', opts.model); const modelPath = this.resolveModelToPath(opts.model); console.log('[MODELDBG] initAll.modelPath (resolved)=', modelPath); this.lastLocale = opts.locale; this.lastModel = modelPath; if (Platform.OS === 'ios' && NativeSpeech?.initAll) { this.iosTtsOnly = false; // full unified mode this.teardownListeners(); // re-wire listeners for unified const r = await NativeSpeech.initAll({ ...opts, model: modelPath }); this.ensureListeners(); return r; } // Fallback (Android or iOS w/o SpeechBridge): // 1) Start STT (engine hot will happen internally); 2) init TTS. if (!NativeSTT || !NativeTTS) { throw new Error('Missing native bridges (STT/TTS).'); } // Start STT (best-effort; no-op if already running) await new Promise<void>((resolve, reject) => { try { // iOS fallback signature: (locale, cb) // Android signature: (locale, extras, cb) if (Platform.OS === 'android') { // Always try Android 3-arg signature first, then fall back try { NativeSTT.startSpeech( opts.locale, { EXTRA_LANGUAGE_MODEL: 'LANGUAGE_MODEL_FREE_FORM', EXTRA_MAX_RESULTS: 5, EXTRA_PARTIAL_RESULTS: true, REQUEST_PERMISSIONS_AUTO: true, }, (err: string) => (err ? reject(new Error(err)) : resolve()) ); } catch { // Fallback to 2-arg (some RN voice bridges use this) NativeSTT.startSpeech(opts.locale, (err: string) => err ? reject(new Error(err)) : resolve() ); } } else { NativeSTT.startSpeech(opts.locale, (err: string) => err ? reject(new Error(err)) : resolve(), ); } } catch (e) { reject(e as any); } }); // Init TTS await NativeTTS.initTTS({ model: modelPath }); } async destroyAll() { // iOS unified if (Platform.OS === 'ios' && NativeSpeech?.destroyAll) { const r = await NativeSpeech.destroyAll(); this.iosTtsOnly = false; this.lastLocale = this.lastLocale ?? null; this.teardownListeners(); return r; } // Fallback: destroy TTS -> STT try { await NativeTTS?.destroy?.(); } catch {} try { await new Promise<void>((res) => { if (!NativeSTT?.destroySpeech) return res(); NativeSTT.destroySpeech(() => res()); }); } catch {} this.teardownListeners(); return 'Destroyed'; } // ---------- STT ---------- async start(locale: string, options: Record<string, any> = {}) { this.ensureListeners(); // Prefer unified on iOS if (Platform.OS === 'ios' && NativeSpeech?.startSpeech) { return new Promise<void>((resolve) => NativeSpeech.startSpeech(locale, () => resolve())); } // Android + iOS fallback return new Promise<void>((resolve, reject) => { if (!NativeSTT?.startSpeech) return reject(new Error('startSpeech not available')); if (Platform.OS === 'android') { try { NativeSTT.startSpeech( locale, { EXTRA_LANGUAGE_MODEL: 'LANGUAGE_MODEL_FREE_FORM', EXTRA_MAX_RESULTS: 5, EXTRA_PARTIAL_RESULTS: true, REQUEST_PERMISSIONS_AUTO: true, ...options, }, (err: string) => (err ? reject(new Error(err)) : resolve()) ); } catch { // Fallback to 2-arg NativeSTT.startSpeech(locale, (err: string) => err ? reject(new Error(err)) : resolve() ); } } else { NativeSTT.startSpeech(locale, (err: string) => err ? reject(new Error(err)) : resolve(), ); } }); } /** Pause mic/STT (Android native; iOS unified if present) */ async pauseMicrophone(): Promise<void> { console.log('[pauseMicrophone] called'); this.logCall('pauseMicrophone'); // iOS: prefer async first, fallback to callback if missing if (Platform.OS === 'ios' && (NativeSpeech as any)?.pauseMicrophoneAsync) { dbg('IOS [pauseMicrophone] using NativeSpeech.pauseMicrophoneAsync()'); try { const r = await (NativeSpeech as any).pauseMicrophoneAsync(1000); dbg('pauseMicrophoneAsync result', r); if (r?.ok === false) dbgErr('pauseMicrophoneAsync failed', r?.reason); return; } catch (e) { dbgErr('IOS [pauseMicrophone] NativeSpeech.pauseMicrophoneAsync() ERROR:', String(e)); throw e; } } if (Platform.OS === 'ios' && NativeSpeech?.pauseMicrophone) { console.log('IOS [pauseMicrophone] called'); return new Promise((resolve, reject) => { try { (NativeSpeech as any).pauseMicrophone(() => resolve()); } catch (e) { reject(e as any); } }); } if (!(NativeSTT as any)?.pauseMicrophone) return Promise.resolve(); return new Promise((resolve, reject) => { try { (NativeSTT as any).pauseMicrophone(() => resolve()); } catch (e) { reject(e as any); } }); } /** Resume mic/STT (Android native; iOS unified if present) */ async unPauseMicrophone(): Promise<void> { this.logCall('unPauseMicrophone'); // iOS: prefer async first, fallback to callback if missing if (Platform.OS === 'ios' && (NativeSpeech as any)?.unPauseMicrophoneAsync) { dbg('IOS [unPauseMicrophone] using NativeSpeech.unPauseMicrophoneAsync()'); try { const r = await (NativeSpeech as any).unPauseMicrophoneAsync(1000); if (r?.ok === false) dbgErr('pauseMicrophoneAsync failed', r?.reason); dbg('IOS [unPauseMicrophone] NativeSpeech.unPauseMicrophoneAsync() DONE'); return; } catch (e) { dbgErr('IOS [unPauseMicrophone] NativeSpeech.unPauseMicrophoneAsync() ERROR:', String(e)); throw e; } } if (Platform.OS === 'ios' && NativeSpeech?.unPauseMicrophone) { console.log('IOS [unPauseMicrophone] called'); return new Promise((resolve, reject) => { try { (NativeSpeech as any).unPauseMicrophone(() => resolve()); } catch (e) { reject(e as any); } }); } if (Platform.OS === 'ios') console.log('IOS [unPauseMicrophone] called without native support'); if (!(NativeSTT as any)?.unPauseMicrophone) return Promise.resolve(); return new Promise((resolve, reject) => { try { (NativeSTT as any).unPauseMicrophone(() => resolve()); } catch (e) { reject(e as any); } }); } stop(): Promise<void> { if (Platform.OS === 'ios' && NativeSpeech?.stopSpeech) { return new Promise((res) => NativeSpeech.stopSpeech(() => res())); } if (!NativeSTT?.stopSpeech) return Promise.resolve(); return new Promise((res) => NativeSTT.stopSpeech(() => res())); } cancel(): Promise<void> { if (Platform.OS === 'ios' && NativeSpeech?.cancelSpeech) { return new Promise((res) => NativeSpeech.cancelSpeech(() => res())); } if (!NativeSTT?.cancelSpeech) return Promise.resolve(); return new Promise((res) => NativeSTT.cancelSpeech(() => res())); } isAvailable(): Promise<0 | 1> { // Prefer unified if (Platform.OS === 'ios' && NativeSpeech?.isSpeechAvailable) { return new Promise((resolve, reject) => NativeSpeech.isSpeechAvailable((ok: 0 | 1, err: string) => err ? reject(new Error(err)) : resolve(ok), ), ); } if (NativeSTT?.isSpeechAvailable) { return new Promise((resolve) => NativeSTT.isSpeechAvailable((ok: 0 | 1) => resolve(ok)), ); } return Promise.resolve(1); } isRecognizing(): Promise<0 | 1> { if (Platform.OS === 'ios' && NativeSpeech?.isRecognizing) { return new Promise((resolve) => NativeSpeech.isRecognizing((v: 0 | 1) => resolve(v)), ); } if (NativeSTT?.isRecognizing) { return new Promise((resolve) => NativeSTT.isRecognizing((v: 0 | 1) => resolve(v)), ); } return Promise.resolve(0); } // ---------- TTS ---------- async initTTS(modelOrConfig: ModelRef | { model: ModelRef }) { const cfg = modelOrConfig && typeof modelOrConfig === 'object' && 'model' in (modelOrConfig as any) ? (modelOrConfig as any) : { model: modelOrConfig as any }; // // iOS unified asks you to use initAll // if (Platform.OS === 'ios' && NativeSpeech?.initAll) { // throw new Error('Use initAll() on iOS unified bridge.'); // } if (!cfg?.model) throw new Error("initTTS: missing 'model'"); const modelPath = this.resolveModelToPath(cfg.model); this.lastModel = modelPath; return NativeTTS.initTTS({ model: modelPath }); } async speak(text: string, speakerId = 0, speed = 1.0) { // sanitize and invert (avoid NaN/undefined/null) // Reverse speed to length. const s = Number.isFinite(speed as number) && speed !== 0 ? 1.0 / (speed as number) : 1.0; this.ensureListeners(); // MIN: serialize + await actual completion (event-driven) this.ttsChain = this.ttsChain.then(() => this._speakAndWait(text, speakerId, s)); return this.ttsChain; } async stopSpeaking() { if (Platform.OS === 'ios' && NativeSpeech?.stopSpeaking) { return NativeSpeech.stopSpeaking(); } if (!NativeTTS?.stopSpeaking) return; return NativeTTS.stopSpeaking(); } // --- NEW: TTS passthroughs for external audio --- /** Queue a WAV file (local path, file:// URL, or require() asset). */ async playWav(pathOrURL: any, markAsLast = true) { // NEW: resolve require() assets to actual file path/URI console.log('[Speech.playWav] called with:', pathOrURL, '| type:', typeof pathOrURL); //const resolveAssetSource = require('react-native/Libraries/Image/resolveAssetSource').default; const asset = resolveAssetSource(pathOrURL); console.log('[Speech.playWav] resolveAssetSource ->', asset); let realPath = asset?.uri ?? pathOrURL; // fallback keeps string path intact console.log('[Speech.playWav] resolved realPath:', realPath); // ✅ Coerce to string explicitly if (typeof realPath !== 'string') { realPath = String(realPath); console.log('[Speech.playWav] converted ?? realPath:', realPath); } console.log('[Speech.playWav] before checking ios realPath:', realPath); // Prefer unified iOS bridge if present if (Platform.OS === 'ios' && NativeSpeech?.playWav) { return NativeSpeech.playWav(realPath, markAsLast); } console.log('[Speech.playWav] after checking ios realPath:', realPath); console.log('[Speech.playWav] after checking ios realPath:', typeof(realPath)); // Fallback: direct TTS bridge (Android + iOS fallback) if (!NativeTTS?.playWav) { console.log('[Speech.playWav] NativeTTS:', NativeTTS); if (NativeTTS) console.log('[Speech.playWav] NativeTTS.playWav :', NativeTTS.playWav); throw new Error('playWav not available on this platform.'); } console.log('[Speech.playWav] calling NativeTTS.playWav with type of realPath:', typeof(realPath)); return NativeTTS.playWav(realPath, markAsLast); } // /** Queue a WAV file (local path or file:// URL). Routed via AEC path, queued with speak(). */ // async playWav(pathOrURL: string, markAsLast = true) { // // Prefer unified iOS bridge if present // if (Platform.OS === 'ios' && NativeSpeech?.playWav) { // return NativeSpeech.playWav(pathOrURL, markAsLast); // } // // Fallback: direct TTS bridge (Android + iOS fallback) // if (!NativeTTS?.playWav) throw new Error('playWav not available on this platform.'); // return NativeTTS.playWav(pathOrURL, markAsLast); // } /** * Convenience: queue a typed array (Int16Array | Float32Array | ArrayBuffer) as PCM. * We’ll base64 it and pass through to native with the right metadata. */ async playPCM( data: ArrayBuffer | Int16Array | Float32Array, opts: { sampleRate: number; channels?: number; interleaved?: boolean; /** If data is Int16Array'i16' (default); if Float32Array'f32' (default) */ format?: 'i16' | 'f32'; markAsLast?: boolean; } ) { let u8: Uint8Array; let format: 'i16' | 'f32' = opts.format ?? 'i16'; if (data instanceof ArrayBuffer) { // assume Int16 unless caller specified u8 = new Uint8Array(data); } else if (data instanceof Int16Array) { u8 = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); format = opts.format ?? 'i16'; } else if (data instanceof Float32Array) { u8 = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); format = opts.format ?? 'f32'; } else { throw new Error('Unsupported PCM container'); } const base64 = toBase64(u8); return this.playBuffer({ base64, sampleRate: opts.sampleRate, channels: opts.channels ?? 1, interleaved: opts.interleaved ?? true, format, markAsLast: opts.markAsLast ?? true, }); } /** * Queue raw PCM buffer from other TTS providers (base64 payload). * Use ExternalPCM for full control of metadata. */ async playBuffer(desc: ExternalPCM) { const payload = { base64: desc.base64, sampleRate: desc.sampleRate, channels: desc.channels ?? 1, interleaved: desc.interleaved ?? true, format: desc.format, markAsLast: desc.markAsLast ?? true, }; if (Platform.OS === 'ios' && NativeSpeech?.playBuffer) { return NativeSpeech.playBuffer(payload); } if (!NativeTTS?.playBuffer) throw new Error('playBuffer not available on this platform.'); return NativeTTS.playBuffer(payload); } // ---------- Events ---------- private ensureListeners() { if (this.subs.length) return; // iOS unified: subscribe once on the unified emitter if (Platform.OS === 'ios' && this.unifiedEmitter) { const map: Record<NativeEventName, (...args: any[]) => void> = { onSpeechStart: (e) => this.handlers.onSpeechStart(e), onSpeechRecognized: (e) => this.handlers.onSpeechRecognized(e), onSpeechEnd: (e) => this.handlers.onSpeechEnd(e), onSpeechError: (e) => this.handlers.onSpeechError(e), onSpeechResults: (e) => this.handlers.onSpeechResults(e), onSpeechPartialResults: (e) => this.handlers.onSpeechPartialResults(e), onSpeechVolumeChanged: (e) => this.handlers.onSpeechVolumeChanged(e), onFinishedSpeaking: () => this._onNativeFinishedSpeaking(), }; (Object.keys(map) as NativeEventName[]).forEach((name) => { try { const sub = this.unifiedEmitter!.addListener(name, map[name]); this.subs.push(sub); } catch {} }); return; } // Android (and iOS fallback): subscribe to both STT and TTS emitters if (this.sttEmitter) { const sttMap = { onSpeechStart: (e: any) => this.handlers.onSpeechStart(e), onSpeechRecognized: (e: any) => this.handlers.onSpeechRecognized(e), onSpeechEnd: (e: any) => this.handlers.onSpeechEnd(e), onSpeechError: (e: any) => this.handlers.onSpeechError(e), onSpeechResults: (e: any) => this.handlers.onSpeechResults(e), onSpeechPartialResults: (e: any) => this.handlers.onSpeechPartialResults(e), onSpeechVolumeChanged: (e: any) => this.handlers.onSpeechVolumeChanged(e), onNewSpeechWAV: (e: any) => this.handlers.onNewSpeechWAV(e), }; (Object.keys(sttMap) as (keyof typeof sttMap)[]).forEach((name) => { try { const sub = this.sttEmitter!.addListener(name, sttMap[name]); this.subs.push(sub); } catch {} }); } if (this.ttsEmitter) { try { // MIN: prevent duplicate listeners across Fast Refresh / reload const g: any = globalThis as any; try { g.__SpeechJS_finishedSub?.remove?.(); } catch {} const sub = this.ttsEmitter.addListener('onFinishedSpeaking', () => this._onNativeFinishedSpeaking()); g.__SpeechJS_finishedSub = sub; this.subs.push(sub); } catch {} } } private teardownListeners() { this.subs.forEach(s => { try { s.remove(); } catch {} }); this.subs = []; } // ---------- Friendly setters ---------- set onSpeechStart(fn: (e: SpeechStartEvent) => void) { this.handlers.onSpeechStart = fn; this.ensureListeners(); } set onSpeechRecognized(fn: (e: SpeechRecognizedEvent) => void) { this.handlers.onSpeechRecognized = fn; this.ensureListeners(); } set onSpeechEnd(fn: (e: SpeechEndEvent) => void) { this.handlers.onSpeechEnd = fn; this.ensureListeners(); } set onSpeechError(fn: (e: SpeechErrorEvent) => void) { this.handlers.onSpeechError = fn; this.ensureListeners(); } set onSpeechResults(fn: (e: SpeechResultsEvent) => void) { this.handlers.onSpeechResults = fn; this.ensureListeners(); } set onSpeechPartialResults(fn: (e: SpeechResultsEvent) => void) { this.handlers.onSpeechPartialResults = fn; this.ensureListeners(); } set onSpeechVolumeChanged(fn: (e: SpeechVolumeChangeEvent) => void) { this.handlers.onSpeechVolumeChanged = fn; this.ensureListeners(); } set onNewSpeechWAV(fn: (e: NewSpeechWAVEvent) => void) { this.handlers.onNewSpeechWAV = fn; this.ensureListeners(); } set onFinishedSpeaking(fn: () => void) { this.handlers.onFinishedSpeaking = fn; this.ensureListeners(); } } const SpeechInstance = new Speech(); export default SpeechInstance;