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, };