diff --git a/app/(auth)/purchase.tsx b/app/(auth)/purchase.tsx
index 04eeba0..814e784 100644
--- a/app/(auth)/purchase.tsx
+++ b/app/(auth)/purchase.tsx
@@ -21,8 +21,8 @@ const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
const STARTER_KIT = {
name: 'WellNuo Starter Kit',
- price: '$249',
- priceValue: 249,
+ price: '$399',
+ priceValue: 399,
};
export default function PurchaseScreen() {
@@ -260,7 +260,7 @@ export default function PurchaseScreen() {
{STARTER_KIT.price}
- 4 smart sensors that easily plug into any outlet and set up through the app in minutes
+ 5 smart sensors that easily plug into any outlet and set up through the app in minutes
{/* Security Badge */}
@@ -282,7 +282,7 @@ export default function PurchaseScreen() {
) : (
<>
- Buy Now - {STARTER_KIT.price}
+ Buy Now
>
)}
diff --git a/app/(auth)/verify-otp.tsx b/app/(auth)/verify-otp.tsx
index b00816f..8744f01 100644
--- a/app/(auth)/verify-otp.tsx
+++ b/app/(auth)/verify-otp.tsx
@@ -185,6 +185,17 @@ export default function VerifyOTPScreen() {
const success = await verifyOtp(email, codeToVerify);
if (success) {
+ // If user has invite code, try to accept it (silent - don't block flow)
+ if (inviteCode) {
+ console.log('[VerifyOTP] Accepting invite code:', inviteCode);
+ const inviteResult = await api.acceptInvitation(inviteCode);
+ if (inviteResult.ok) {
+ console.log('[VerifyOTP] Invite code accepted:', inviteResult.data?.message);
+ } else {
+ console.warn('[VerifyOTP] Failed to accept invite code:', inviteResult.error?.message);
+ // Don't block - continue with registration flow
+ }
+ }
await navigateAfterSuccess();
return;
}
diff --git a/app/(tabs)/beneficiaries/[id]/purchase.tsx b/app/(tabs)/beneficiaries/[id]/purchase.tsx
index 373a096..e77d3b2 100644
--- a/app/(tabs)/beneficiaries/[id]/purchase.tsx
+++ b/app/(tabs)/beneficiaries/[id]/purchase.tsx
@@ -32,9 +32,9 @@ const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
const STARTER_KIT = {
name: 'WellNuo Starter Kit',
- price: '$249',
- priceValue: 249,
- description: '4 smart sensors that easily plug into any outlet and set up through the app in minutes',
+ price: '$399',
+ priceValue: 399,
+ description: '5 smart sensors that easily plug into any outlet and set up through the app in minutes',
};
export default function PurchaseScreen() {
@@ -245,7 +245,7 @@ export default function PurchaseScreen() {
) : (
<>
- Buy Now — {STARTER_KIT.price}
+ Buy Now
>
)}
diff --git a/backend/scripts/setup-stripe-products.js b/backend/scripts/setup-stripe-products.js
index 4bc97c5..9a52af2 100644
--- a/backend/scripts/setup-stripe-products.js
+++ b/backend/scripts/setup-stripe-products.js
@@ -24,16 +24,16 @@ async function setupStripeProducts() {
});
console.log(`✓ Product created: ${starterKit.id}`);
- // Create price for Starter Kit ($249 one-time)
+ // Create price for Starter Kit ($399 one-time)
const starterKitPrice = await stripe.prices.create({
product: starterKit.id,
- unit_amount: 24900, // $249.00
+ unit_amount: 39900, // $399.00
currency: 'usd',
metadata: {
display_name: 'Starter Kit'
}
});
- console.log(`✓ Price created: ${starterKitPrice.id} ($249.00)\n`);
+ console.log(`✓ Price created: ${starterKitPrice.id} ($399.00)\n`);
// 2. Create Premium Subscription product
console.log('Creating Premium Subscription product...');
diff --git a/backend/src/config/stripe.js b/backend/src/config/stripe.js
index 791e0e0..0b524c7 100644
--- a/backend/src/config/stripe.js
+++ b/backend/src/config/stripe.js
@@ -9,7 +9,7 @@ const PRODUCTS = {
STARTER_KIT: {
name: 'WellNuo Starter Kit',
description: '2x Motion Sensors + 1x Door Sensor + 1x Hub',
- price: 24900, // $249.00 in cents
+ price: 39900, // $399.00 in cents
type: 'one_time'
},
PREMIUM_SUBSCRIPTION: {
diff --git a/components/screens/auth-purchase/PurchaseScreen.native.tsx b/components/screens/auth-purchase/PurchaseScreen.native.tsx
index 04eeba0..814e784 100644
--- a/components/screens/auth-purchase/PurchaseScreen.native.tsx
+++ b/components/screens/auth-purchase/PurchaseScreen.native.tsx
@@ -21,8 +21,8 @@ const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
const STARTER_KIT = {
name: 'WellNuo Starter Kit',
- price: '$249',
- priceValue: 249,
+ price: '$399',
+ priceValue: 399,
};
export default function PurchaseScreen() {
@@ -260,7 +260,7 @@ export default function PurchaseScreen() {
{STARTER_KIT.price}
- 4 smart sensors that easily plug into any outlet and set up through the app in minutes
+ 5 smart sensors that easily plug into any outlet and set up through the app in minutes
{/* Security Badge */}
@@ -282,7 +282,7 @@ export default function PurchaseScreen() {
) : (
<>
- Buy Now - {STARTER_KIT.price}
+ Buy Now
>
)}
diff --git a/components/screens/auth-purchase/PurchaseScreen.web.tsx b/components/screens/auth-purchase/PurchaseScreen.web.tsx
index 90ab36d..62b5ca3 100644
--- a/components/screens/auth-purchase/PurchaseScreen.web.tsx
+++ b/components/screens/auth-purchase/PurchaseScreen.web.tsx
@@ -22,8 +22,8 @@ import { hasBeneficiaryDevices } from '@/services/BeneficiaryDetailController';
const STARTER_KIT = {
name: 'WellNuo Starter Kit',
- price: '$249',
- priceValue: 249,
+ price: '$399',
+ priceValue: 399,
};
export default function PurchaseScreen() {
@@ -158,7 +158,7 @@ export default function PurchaseScreen() {
{STARTER_KIT.price}
- 4 smart sensors that easily plug into any outlet and set up through the app in minutes
+ 5 smart sensors that easily plug into any outlet and set up through the app in minutes
{/* Security Badge */}
@@ -180,7 +180,7 @@ export default function PurchaseScreen() {
) : (
<>
- Buy Now - {STARTER_KIT.price}
+ Buy Now
>
)}
diff --git a/components/screens/purchase/PurchaseScreen.native.tsx b/components/screens/purchase/PurchaseScreen.native.tsx
index 373a096..e77d3b2 100644
--- a/components/screens/purchase/PurchaseScreen.native.tsx
+++ b/components/screens/purchase/PurchaseScreen.native.tsx
@@ -32,9 +32,9 @@ const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
const STARTER_KIT = {
name: 'WellNuo Starter Kit',
- price: '$249',
- priceValue: 249,
- description: '4 smart sensors that easily plug into any outlet and set up through the app in minutes',
+ price: '$399',
+ priceValue: 399,
+ description: '5 smart sensors that easily plug into any outlet and set up through the app in minutes',
};
export default function PurchaseScreen() {
@@ -245,7 +245,7 @@ export default function PurchaseScreen() {
) : (
<>
- Buy Now — {STARTER_KIT.price}
+ Buy Now
>
)}
diff --git a/components/screens/purchase/PurchaseScreen.web.tsx b/components/screens/purchase/PurchaseScreen.web.tsx
index 9d1989c..87d1b5a 100644
--- a/components/screens/purchase/PurchaseScreen.web.tsx
+++ b/components/screens/purchase/PurchaseScreen.web.tsx
@@ -33,9 +33,9 @@ import { hasBeneficiaryDevices } from '@/services/BeneficiaryDetailController';
const STARTER_KIT = {
name: 'WellNuo Starter Kit',
- price: '$249',
- priceValue: 249,
- description: '4 smart sensors that easily plug into any outlet and set up through the app in minutes',
+ price: '$399',
+ priceValue: 399,
+ description: '5 smart sensors that easily plug into any outlet and set up through the app in minutes',
};
export default function PurchaseScreen() {
@@ -167,7 +167,7 @@ export default function PurchaseScreen() {
) : (
<>
- Buy Now — {STARTER_KIT.price}
+ Buy Now
>
)}
diff --git a/package-lock.json b/package-lock.json
index 9d8f983..4218322 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"@expo/vector-icons": "^15.0.3",
+ "@orbital-systems/react-native-esp-idf-provisioning": "^0.5.0",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
@@ -21,7 +22,6 @@
"expo-camera": "~17.0.10",
"expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.12",
- "expo-crypto": "~15.0.8",
"expo-file-system": "~19.0.21",
"expo-font": "~14.0.10",
"expo-haptics": "~15.0.8",
@@ -43,6 +43,7 @@
"react-native": "0.81.5",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "~2.28.0",
+ "react-native-get-random-values": "^2.0.0",
"react-native-reanimated": "~4.1.1",
"react-native-root-toast": "^4.0.1",
"react-native-safe-area-context": "~5.6.0",
@@ -4699,6 +4700,46 @@
"node": ">=10"
}
},
+ "node_modules/@orbital-systems/react-native-esp-idf-provisioning": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/@orbital-systems/react-native-esp-idf-provisioning/-/react-native-esp-idf-provisioning-0.5.0.tgz",
+ "integrity": "sha512-CDwWVkRCgZF4zLWR4F/R1G5JzXPBxS5leURpYnKhpoKz9xJzxixa8gMJZes/zUYeSoDDHpIZo15YrorCJuQjFA==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^6.0.3"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
+ "node_modules/@orbital-systems/react-native-esp-idf-provisioning/node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -10699,18 +10740,6 @@
"react-native": "*"
}
},
- "node_modules/expo-crypto": {
- "version": "15.0.8",
- "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.8.tgz",
- "integrity": "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw==",
- "license": "MIT",
- "dependencies": {
- "base64-js": "^1.3.0"
- },
- "peerDependencies": {
- "expo": "*"
- }
- },
"node_modules/expo-file-system": {
"version": "19.0.21",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
@@ -11409,6 +11438,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/fast-base64-decode": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fast-base64-decode/-/fast-base64-decode-1.0.0.tgz",
+ "integrity": "sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q==",
+ "license": "MIT"
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -18789,6 +18824,18 @@
"react-native": "*"
}
},
+ "node_modules/react-native-get-random-values": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-2.0.0.tgz",
+ "integrity": "sha512-wx7/aPqsUIiWsG35D+MsUJd8ij96e3JKddklSdrdZUrheTx89gPtz3Q2yl9knBArj5u26Cl23T88ai+Q0vypdQ==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-base64-decode": "^1.0.0"
+ },
+ "peerDependencies": {
+ "react-native": ">=0.81"
+ }
+ },
"node_modules/react-native-is-edge-to-edge": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz",
diff --git a/package.json b/package.json
index e6cf09c..68fb7fe 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
+ "@orbital-systems/react-native-esp-idf-provisioning": "^0.5.0",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
@@ -24,7 +25,6 @@
"expo-camera": "~17.0.10",
"expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.12",
- "expo-crypto": "~15.0.8",
"expo-file-system": "~19.0.21",
"expo-font": "~14.0.10",
"expo-haptics": "~15.0.8",
@@ -46,6 +46,7 @@
"react-native": "0.81.5",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "~2.28.0",
+ "react-native-get-random-values": "^2.0.0",
"react-native-reanimated": "~4.1.1",
"react-native-root-toast": "^4.0.1",
"react-native-safe-area-context": "~5.6.0",
diff --git a/services/api.ts b/services/api.ts
index dd651a0..c37e425 100644
--- a/services/api.ts
+++ b/services/api.ts
@@ -1,8 +1,8 @@
import type { ApiError, ApiResponse, AuthResponse, Beneficiary, BeneficiaryDashboardData, ChatResponse, DashboardSingleResponse, NotificationSettings } from '@/types';
-import * as Crypto from 'expo-crypto';
import { File } from 'expo-file-system';
import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage';
+import 'react-native-get-random-values'; // Polyfill for crypto.getRandomValues
// Callback for handling unauthorized responses (401)
let onUnauthorizedCallback: (() => void) | null = null;
@@ -67,7 +67,9 @@ class ApiService {
}
private generateNonce(): string {
- const randomBytes = Crypto.getRandomBytes(16);
+ // Use Web Crypto API (polyfilled by react-native-get-random-values)
+ const randomBytes = new Uint8Array(16);
+ crypto.getRandomValues(randomBytes);
return Array.from(randomBytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
@@ -1080,6 +1082,51 @@ class ApiService {
}
}
+ // Accept invitation code
+ async acceptInvitation(code: string): Promise> {
+ const token = await this.getToken();
+ if (!token) {
+ return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
+ }
+
+ try {
+ const response = await fetch(`${WELLNUO_API_URL}/invitations/accept`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ code }),
+ });
+
+ const data = await response.json();
+
+ if (response.ok) {
+ return { data, ok: true };
+ }
+
+ return {
+ ok: false,
+ error: { message: data.error || 'Failed to accept invitation' },
+ };
+ } catch (error) {
+ return {
+ ok: false,
+ error: { message: 'Network error. Please check your connection.' },
+ };
+ }
+ }
+
// Activate equipment for beneficiary (saves to server)
async activateBeneficiary(beneficiaryId: number, serialNumber: string): Promise_
macPart?: string; // Last part of MAC address
}
@@ -37,14 +33,44 @@ export interface WifiNetwork {
auth: string;
}
+// Dynamic import types - will be null in Expo Go
+let ESPProvisionManager: any = null;
+let ESPTransport: any = null;
+let ESPSecurity: any = null;
+
+// Try to load native module only in development builds
+if (!isExpoGo) {
+ try {
+ const espModule = require('@orbital-systems/react-native-esp-idf-provisioning');
+ ESPProvisionManager = espModule.ESPProvisionManager;
+ ESPTransport = espModule.ESPTransport;
+ ESPSecurity = espModule.ESPSecurity;
+ console.log('[ESP] Native provisioning module loaded');
+ } catch (e) {
+ console.warn('[ESP] Native provisioning module not available:', e);
+ }
+}
+
class ESPProvisioningService {
- private connectedDevice: ESPDevice | null = null;
+ private connectedDevice: any | null = null;
private isScanning = false;
+ /**
+ * Check if ESP provisioning is available (development build only)
+ */
+ isAvailable(): boolean {
+ return ESPProvisionManager !== null;
+ }
+
/**
* Request necessary permissions for BLE on Android
*/
async requestPermissions(): Promise {
+ if (!this.isAvailable()) {
+ console.warn('[ESP] Provisioning not available in Expo Go');
+ return false;
+ }
+
if (Platform.OS !== 'android') {
return true;
}
@@ -76,6 +102,16 @@ class ESPProvisioningService {
* Returns list of devices matching WP_xxx_xxxxxx pattern
*/
async scanForDevices(timeoutMs = 10000): Promise {
+ if (!this.isAvailable()) {
+ console.warn('[ESP] Scan not available - running in Expo Go');
+ Alert.alert(
+ 'Development Build Required',
+ 'WiFi provisioning requires a development build. This feature is not available in Expo Go.',
+ [{ text: 'OK' }]
+ );
+ return [];
+ }
+
if (this.isScanning) {
console.warn('[ESP] Already scanning, please wait');
return [];
@@ -93,12 +129,12 @@ class ESPProvisioningService {
const devices = await ESPProvisionManager.searchESPDevices(
WELLNUO_DEVICE_PREFIX,
ESPTransport.ble,
- DEFAULT_SECURITY
+ ESPSecurity.unsecure
);
console.log(`[ESP] Found ${devices.length} WellNuo device(s)`);
- return devices.map((device: ESPDevice) => {
+ return devices.map((device: any) => {
// Parse device name: WP__
const parts = device.name?.split('_') || [];
return {
@@ -122,9 +158,14 @@ class ESPProvisioningService {
* @param proofOfPossession - Optional PoP for secure devices
*/
async connect(
- device: ESPDevice,
+ device: any,
proofOfPossession?: string
): Promise {
+ if (!this.isAvailable()) {
+ console.warn('[ESP] Connect not available - running in Expo Go');
+ return false;
+ }
+
if (this.connectedDevice) {
console.warn('[ESP] Already connected, disconnecting first...');
await this.disconnect();
@@ -148,6 +189,11 @@ class ESPProvisioningService {
* Scan for available WiFi networks through connected device
*/
async scanWifiNetworks(): Promise {
+ if (!this.isAvailable()) {
+ console.warn('[ESP] WiFi scan not available - running in Expo Go');
+ return [];
+ }
+
if (!this.connectedDevice) {
throw new Error('Not connected to any device');
}
@@ -159,7 +205,7 @@ class ESPProvisioningService {
console.log(`[ESP] Found ${wifiList.length} WiFi network(s)`);
- return wifiList.map((wifi: ESPWifi) => ({
+ return wifiList.map((wifi: any) => ({
ssid: wifi.ssid,
rssi: wifi.rssi,
auth: this.getAuthModeName(wifi.auth),
@@ -176,6 +222,11 @@ class ESPProvisioningService {
* @param password - WiFi password
*/
async provisionWifi(ssid: string, password: string): Promise {
+ if (!this.isAvailable()) {
+ console.warn('[ESP] Provisioning not available - running in Expo Go');
+ return false;
+ }
+
if (!this.connectedDevice) {
throw new Error('Not connected to any device');
}
@@ -246,4 +297,4 @@ class ESPProvisioningService {
export const espProvisioning = new ESPProvisioningService();
// Export types for components
-export type { ESPDevice };
+export type ESPDevice = any;
diff --git a/services/sherpaTTS.ts b/services/sherpaTTS.ts
index e79c1d8..a7c20b9 100644
--- a/services/sherpaTTS.ts
+++ b/services/sherpaTTS.ts
@@ -1,10 +1,13 @@
/**
- * Sherpa TTS Service - STUB for Expo Go development
- * The real implementation requires native modules which don't work in Expo Go
- * This stub returns "not available" so the app falls back to expo-speech
+ * Sherpa TTS Service - Native implementation for offline Text-to-Speech
+ * Uses react-native-sherpa-onnx-offline-tts with Piper VITS models
*/
-// Available Piper neural voices - kept for type compatibility
+import { Platform, NativeModules, NativeEventEmitter } from 'react-native';
+import * as FileSystem from 'expo-file-system';
+import { Asset } from 'expo-asset';
+
+// Available Piper neural voices
export interface PiperVoice {
id: string;
name: string;
@@ -25,23 +28,57 @@ export const AVAILABLE_VOICES: PiperVoice[] = [
gender: 'female',
accent: 'US',
},
+ {
+ id: 'ryan',
+ name: 'Ryan',
+ description: 'American Male (Natural)',
+ modelDir: 'vits-piper-en_US-ryan-medium',
+ onnxFile: 'en_US-ryan-medium.onnx',
+ gender: 'male',
+ accent: 'US',
+ },
+ {
+ id: 'alba',
+ name: 'Alba',
+ description: 'British Female',
+ modelDir: 'vits-piper-en_GB-alba-medium',
+ onnxFile: 'en_GB-alba-medium.onnx',
+ gender: 'female',
+ accent: 'UK',
+ },
];
interface SherpaTTSState {
initialized: boolean;
initializing: boolean;
error: string | null;
+ currentVoice: PiperVoice;
+}
+
+// Check if native module is available
+const TTSManager = NativeModules.TTSManager;
+const NATIVE_MODULE_AVAILABLE = !!TTSManager;
+
+let ttsManagerEmitter: NativeEventEmitter | null = null;
+if (NATIVE_MODULE_AVAILABLE) {
+ ttsManagerEmitter = new NativeEventEmitter(TTSManager);
}
let currentState: SherpaTTSState = {
initialized: false,
initializing: false,
- error: 'Sherpa TTS disabled for Expo Go development',
+ error: null,
+ currentVoice: AVAILABLE_VOICES[0],
};
// State listeners
const stateListeners: ((state: SherpaTTSState) => void)[] = [];
+function updateState(updates: Partial) {
+ currentState = { ...currentState, ...updates };
+ notifyListeners();
+}
+
function notifyListeners() {
stateListeners.forEach(listener => listener({ ...currentState }));
}
@@ -59,12 +96,179 @@ export function getState(): SherpaTTSState {
return { ...currentState };
}
-// Stub implementations - always return false/unavailable
-export async function initializeSherpaTTS(): Promise {
- console.log('[SherpaTTS STUB] Sherpa TTS disabled for Expo Go - using expo-speech instead');
- return false;
+/**
+ * Copy bundled TTS model assets to document directory for native access
+ * NOTE: Temporarily disabled dynamic requires for Metro bundler compatibility
+ */
+async function copyModelToDocuments(voice: PiperVoice): Promise {
+ // TEMP: Skip dynamic requires - TTS models will be loaded differently
+ console.log('[SherpaTTS] copyModelToDocuments temporarily disabled');
+ return null;
+
+ /* DISABLED - dynamic requires don't work with Metro bundler
+ try {
+ const destDir = `${FileSystem.documentDirectory}tts-models/${voice.modelDir}`;
+ const onnxPath = `${destDir}/${voice.onnxFile}`;
+
+ // Check if already copied
+ const onnxInfo = await FileSystem.getInfoAsync(onnxPath);
+ if (onnxInfo.exists) {
+ console.log('[SherpaTTS] Model already exists at:', destDir);
+ return destDir;
+ }
+
+ console.log('[SherpaTTS] Copying model to documents directory...');
+
+ // Create destination directory
+ await FileSystem.makeDirectoryAsync(destDir, { intermediates: true });
+
+ // For Expo, we need to copy from assets
+ // The models are in assets/tts-models/
+ const assetBase = `../assets/tts-models/${voice.modelDir}`;
+
+ // Copy main ONNX file
+ const onnxAsset = Asset.fromModule(require(`../assets/tts-models/${voice.modelDir}/${voice.onnxFile}`));
+ await onnxAsset.downloadAsync();
+ if (onnxAsset.localUri) {
+ await FileSystem.copyAsync({
+ from: onnxAsset.localUri,
+ to: onnxPath,
+ });
+ }
+
+ // Copy tokens.txt
+ const tokensAsset = Asset.fromModule(require(`../assets/tts-models/${voice.modelDir}/tokens.txt`));
+ await tokensAsset.downloadAsync();
+ if (tokensAsset.localUri) {
+ await FileSystem.copyAsync({
+ from: tokensAsset.localUri,
+ to: `${destDir}/tokens.txt`,
+ });
+ }
+
+ // Copy espeak-ng-data directory
+ // This is more complex - need to copy entire directory
+ const espeakDestDir = `${destDir}/espeak-ng-data`;
+ await FileSystem.makeDirectoryAsync(espeakDestDir, { intermediates: true });
+
+ // For now, we'll use the bundle path directly on iOS
+ console.log('[SherpaTTS] Model copied successfully');
+ return destDir;
+
+ } catch (error) {
+ console.error('[SherpaTTS] Error copying model:', error);
+ return null;
+ }
+ */
}
+/**
+ * Get the path to bundled models (iOS bundle or Android assets)
+ */
+function getBundledModelPath(voice: PiperVoice): string | null {
+ if (Platform.OS === 'ios') {
+ // On iOS, assets are in the main bundle
+ const bundlePath = NativeModules.RNFSManager?.MainBundlePath || '';
+ if (!bundlePath) {
+ // Try to construct path from FileSystem
+ // Models should be copied during pod install or prebuild
+ return null;
+ }
+ return `${bundlePath}/assets/tts-models/${voice.modelDir}`;
+ } else if (Platform.OS === 'android') {
+ // On Android, assets are extracted to files dir
+ return `${FileSystem.documentDirectory}tts-models/${voice.modelDir}`;
+ }
+ return null;
+}
+
+/**
+ * Initialize Sherpa TTS with a specific voice model
+ */
+export async function initializeSherpaTTS(voice?: PiperVoice): Promise {
+ if (!NATIVE_MODULE_AVAILABLE) {
+ console.log('[SherpaTTS] Native module not available (Expo Go mode)');
+ updateState({
+ initialized: false,
+ error: 'Native module not available - use native build'
+ });
+ return false;
+ }
+
+ if (currentState.initializing) {
+ console.log('[SherpaTTS] Already initializing...');
+ return false;
+ }
+
+ const selectedVoice = voice || currentState.currentVoice;
+ updateState({ initializing: true, error: null });
+
+ try {
+ console.log('[SherpaTTS] Initializing with voice:', selectedVoice.name);
+
+ // Get model paths
+ // For native build, models should be in the app bundle
+ // We use FileSystem.bundleDirectory on iOS
+ let modelBasePath: string;
+
+ if (Platform.OS === 'ios') {
+ // iOS: Models are copied to bundle during build
+ // Access via MainBundle
+ const mainBundle = await FileSystem.getInfoAsync(FileSystem.bundleDirectory || '');
+ if (mainBundle.exists) {
+ modelBasePath = `${FileSystem.bundleDirectory}assets/tts-models/${selectedVoice.modelDir}`;
+ } else {
+ // Fallback: try document directory
+ modelBasePath = `${FileSystem.documentDirectory}tts-models/${selectedVoice.modelDir}`;
+ }
+ } else {
+ // Android: Extract from assets to document directory
+ modelBasePath = `${FileSystem.documentDirectory}tts-models/${selectedVoice.modelDir}`;
+ }
+
+ // Check if model exists
+ const modelPath = `${modelBasePath}/${selectedVoice.onnxFile}`;
+ const tokensPath = `${modelBasePath}/tokens.txt`;
+ const dataDirPath = `${modelBasePath}/espeak-ng-data`;
+
+ console.log('[SherpaTTS] Model paths:', { modelPath, tokensPath, dataDirPath });
+
+ // Create config JSON for native module
+ const config = JSON.stringify({
+ modelPath,
+ tokensPath,
+ dataDirPath,
+ });
+
+ // Initialize native TTS
+ // Sample rate 22050, mono channel
+ TTSManager.initializeTTS(22050, 1, config);
+
+ updateState({
+ initialized: true,
+ initializing: false,
+ currentVoice: selectedVoice,
+ error: null
+ });
+
+ console.log('[SherpaTTS] Initialized successfully');
+ return true;
+
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ console.error('[SherpaTTS] Initialization error:', errorMessage);
+ updateState({
+ initialized: false,
+ initializing: false,
+ error: errorMessage
+ });
+ return false;
+ }
+}
+
+/**
+ * Speak text using Sherpa TTS
+ */
export async function speak(
text: string,
options?: {
@@ -75,30 +279,108 @@ export async function speak(
onError?: (error: Error) => void;
}
): Promise {
- options?.onError?.(new Error('Sherpa TTS disabled for Expo Go'));
+ if (!NATIVE_MODULE_AVAILABLE || !currentState.initialized) {
+ options?.onError?.(new Error('Sherpa TTS not initialized'));
+ return;
+ }
+
+ const speed = options?.speed ?? 1.0;
+ const speakerId = options?.speakerId ?? 0;
+
+ try {
+ options?.onStart?.();
+
+ await TTSManager.generateAndPlay(text, speakerId, speed);
+
+ options?.onDone?.();
+ } catch (error) {
+ const err = error instanceof Error ? error : new Error('TTS playback failed');
+ console.error('[SherpaTTS] Speak error:', err);
+ options?.onError?.(err);
+ }
}
+/**
+ * Stop current speech playback
+ */
export function stop(): void {
- // No-op
+ if (NATIVE_MODULE_AVAILABLE && currentState.initialized) {
+ try {
+ TTSManager.deinitialize();
+ // Re-initialize after stop to be ready for next speech
+ setTimeout(() => {
+ if (currentState.currentVoice) {
+ initializeSherpaTTS(currentState.currentVoice);
+ }
+ }, 100);
+ } catch (error) {
+ console.error('[SherpaTTS] Stop error:', error);
+ }
+ }
}
+/**
+ * Deinitialize and free resources
+ */
export function deinitialize(): void {
- // No-op
+ if (NATIVE_MODULE_AVAILABLE) {
+ try {
+ TTSManager.deinitialize();
+ } catch (error) {
+ console.error('[SherpaTTS] Deinitialize error:', error);
+ }
+ }
+ updateState({ initialized: false, error: null });
}
+/**
+ * Check if Sherpa TTS is available (native module loaded)
+ */
export function isAvailable(): boolean {
- return false;
+ return NATIVE_MODULE_AVAILABLE && currentState.initialized;
}
+/**
+ * Get current voice
+ */
export function getCurrentVoice(): PiperVoice {
- return AVAILABLE_VOICES[0];
+ return currentState.currentVoice;
}
+/**
+ * Set and switch to a different voice
+ */
export async function setVoice(voiceId: string): Promise {
- return false;
+ const voice = AVAILABLE_VOICES.find(v => v.id === voiceId);
+ if (!voice) {
+ console.error('[SherpaTTS] Voice not found:', voiceId);
+ return false;
+ }
+
+ // Deinitialize current and reinitialize with new voice
+ deinitialize();
+ return initializeSherpaTTS(voice);
}
+/**
+ * Add listener for volume updates during playback
+ */
+export function addVolumeListener(callback: (volume: number) => void): (() => void) | null {
+ if (!ttsManagerEmitter) return null;
+
+ const subscription = ttsManagerEmitter.addListener('VolumeUpdate', (event) => {
+ callback(event.volume);
+ });
+
+ return () => subscription.remove();
+}
+
+/**
+ * Load saved voice preference
+ */
export async function loadSavedVoice(): Promise {
+ // For now, just return default voice
+ // Could add AsyncStorage persistence later
return AVAILABLE_VOICES[0];
}
@@ -114,4 +396,5 @@ export default {
getCurrentVoice,
setVoice,
loadSavedVoice,
+ addVolumeListener,
};