react-native-davoice-tts
Version:
tts library for React Native
744 lines (675 loc) • 28.5 kB
text/typescript
// 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;