diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..7b016a8
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "java.compile.nullAnalysis.mode": "automatic"
+}
\ No newline at end of file
diff --git a/MODEL_SETUP.md b/MODEL_SETUP.md
new file mode 100644
index 0000000..1868cf0
--- /dev/null
+++ b/MODEL_SETUP.md
@@ -0,0 +1,232 @@
+# STT Model Setup Guide
+
+## Overview
+
+This app uses RunAnywhere SDK with Sherpa-ONNX Whisper for speech-to-text transcription.
+
+## Automatic Model Download
+
+**The app will automatically download the STT model on first use!**
+
+- Model: `sherpa-onnx-whisper-base.en`
+- Size: ~150MB
+- Download happens automatically when you first start a live session
+- Model is cached locally after download
+
+## What Happens on First Launch
+
+1. App initializes RunAnywhere SDK
+2. Registers default models (including STT)
+3. When you start a live session:
+ - Checks if STT model is downloaded
+ - If not: Downloads it automatically with progress
+ - Loads model into memory
+ - Starts transcription
+
+## Manual Model Management
+
+You can also pre-download models using the ModelService:
+
+```typescript
+import { useModelService } from './services/ModelService';
+
+const { downloadAndLoadSTT, isSTTLoaded } = useModelService();
+
+// Download and load STT model
+await downloadAndLoadSTT();
+```
+
+## Model Information
+
+### Sherpa-ONNX Whisper Base
+
+- **Model ID**: `sherpa-onnx-whisper-base.en`
+- **Size**: ~150MB
+- **Format**: ONNX (tar.gz archive)
+- **Language**: English only
+- **Speed**: Very fast
+- **Accuracy**: Good for most use cases
+- **Source**: https://github.com/RunanywhereAI/sherpa-onnx
+
+## Troubleshooting
+
+### Error: "Failed to load STT model"
+
+**Possible causes:**
+
+1. No internet connection (model needs to download)
+2. Insufficient storage space
+3. Download was interrupted
+
+**Solutions:**
+
+1. Check your internet connection
+2. Ensure you have at least 200MB free space
+3. Restart the app to retry download
+4. Enable Debug Mode to test without the model (see below)
+
+### Error: "Model download failed"
+
+**Solution:**
+
+1. Check internet connection
+2. Try again later (GitHub releases might be temporarily down)
+3. Use Debug Mode to test the app without audio
+
+### Download taking too long?
+
+The model is ~150MB. Download time depends on your connection:
+
+- WiFi: 30 seconds - 2 minutes
+- 4G: 1-3 minutes
+- 3G: 3-5 minutes
+
+You'll see download progress in the logs:
+
+```
+[SpeechService] đĨ Download progress: 10%
+[SpeechService] đĨ Download progress: 20%
+...
+[SpeechService] â
Model downloaded
+```
+
+## Debug Mode (No Model Required)
+
+For testing without a model or internet:
+
+1. Go to **Settings**
+2. Scroll to "**Debug & Testing**"
+3. Toggle "**Debug Mode**" ON
+4. Start a new session
+
+Debug mode injects hardcoded test transcripts every 7 seconds to test the pattern detection pipeline without needing real audio or the STT model.
+
+## Architecture
+
+### Model Loading Flow
+
+```
+App Launch
+ â
+App.tsx: Initialize SDK & Register backends
+ â
+App.tsx: Call registerDefaultModels()
+ ââ Registers LLM model
+ ââ Registers STT model (sherpa-onnx-whisper-base.en)
+ ââ Registers TTS model
+ â
+App.tsx: Call loadSTTModel()
+ ââ Checks if model downloaded
+ ââ Downloads if needed (with progress)
+ ââ Loads model using RunAnywhere.loadSTTModel()
+ â
+User starts live session
+ â
+LiveSessionScreen checks model status
+ ââ If loaded: Start immediately
+ ââ If loading: Wait for it
+ ââ If failed: Offer debug mode
+ â
+Audio captured
+ â
+RunAnywhere.transcribe(audioPath)
+ â
+Transcript returned
+```
+
+### Key Files
+
+- `src/services/SpeechService.ts` - STT model loading & transcription
+- `src/services/ModelService.tsx` - Model registry & download management
+- `src/App.tsx` - App initialization & model preloading
+- `src/screens/LiveSessionScreen.tsx` - Model status checks & error handling
+
+## Logs to Check
+
+**Successful model load:**
+
+```
+[App] đ Starting initialization...
+[App] đ§ Initializing RunAnywhere SDK...
+[App] â
RunAnywhere SDK initialized
+[App] đĻ Registering backends...
+[App] â
Backends registered
+[App] đ¤ Registering default models...
+[App] â
Default models registered
+[App] đ¤ Loading STT model...
+[SpeechService] đ¯ loadSTTModel() called
+[SpeechService] đ¤ Starting STT model load...
+[SpeechService] đ Checking if model is downloaded...
+[SpeechService] â
Model already downloaded
+[SpeechService] đ Loading STT model from: /data/.../sherpa-onnx-whisper-base.en
+[SpeechService] â
STT model loaded successfully in XXXms
+[App] â
STT model ready
+```
+
+**First-time download:**
+
+```
+[SpeechService] đĨ Model not downloaded, downloading...
+[SpeechService] đĨ Download progress: 10%
+[SpeechService] đĨ Download progress: 20%
+...
+[SpeechService] đĨ Download progress: 100%
+[SpeechService] â
Model downloaded
+[SpeechService] đ Loading STT model from: /data/.../sherpa-onnx-whisper-tiny.en
+[SpeechService] â
STT model loaded successfully
+```
+
+## Viewing Logs
+
+### Android
+
+```bash
+adb logcat | grep -E "\[App\]|\[SpeechService\]"
+```
+
+### React Native Debugger
+
+Open the app and check the console for emoji-tagged logs.
+
+## Privacy & Offline Usage
+
+- â
Model downloads once and is cached locally
+- â
All transcription happens on-device
+- â
No external API calls during transcription
+- â
After first download, works in **airplane mode**
+- â
No audio data ever leaves your device
+
+## Alternative Models
+
+You can modify `ModelService.tsx` to use different models:
+
+```typescript
+// In registerDefaultModels()
+await ONNX.addModel({
+ id: 'sherpa-onnx-whisper-base.en', // Larger, more accurate
+ name: 'Sherpa Whisper Base (ONNX)',
+ url: 'https://github.com/RunanywhereAI/sherpa-onnx/releases/download/...',
+ modality: ModelCategory.SpeechRecognition,
+ artifactType: ModelArtifactType.TarGzArchive,
+ memoryRequirement: 150_000_000,
+});
+```
+
+Then update `SpeechService.ts`:
+
+```typescript
+const STT_MODEL_ID = 'sherpa-onnx-whisper-base.en';
+```
+
+## Support
+
+If issues persist:
+
+1. Enable Debug Mode to verify the rest of the app works
+2. Check RunAnywhere SDK docs: https://docs.runanywhere.ai
+3. Verify @runanywhere packages in package.json:
+ - @runanywhere/core: ^0.18.1
+ - @runanywhere/llamacpp: ^0.18.1
+ - @runanywhere/onnx: ^0.18.1
+4. Check logs for specific error messages
+5. Try uninstalling and reinstalling the app
diff --git a/android/app/src/main/assets/fonts/Lora-Regular.ttf b/android/app/src/main/assets/fonts/Lora-Regular.ttf
new file mode 100644
index 0000000..ee1914c
Binary files /dev/null and b/android/app/src/main/assets/fonts/Lora-Regular.ttf differ
diff --git a/android/app/src/main/java/ai/runanywhere/starter/NativeAudioModule.kt b/android/app/src/main/java/ai/runanywhere/starter/NativeAudioModule.kt
index 4802a71..276f0d8 100644
--- a/android/app/src/main/java/ai/runanywhere/starter/NativeAudioModule.kt
+++ b/android/app/src/main/java/ai/runanywhere/starter/NativeAudioModule.kt
@@ -7,6 +7,7 @@ import android.media.AudioRecord
import android.media.AudioTrack
import android.media.MediaRecorder
import android.util.Base64
+import android.util.Log
import androidx.core.app.ActivityCompat
import com.facebook.react.bridge.*
import java.io.ByteArrayOutputStream
@@ -26,11 +27,13 @@ class NativeAudioModule(reactContext: ReactApplicationContext) : ReactContextBas
private var recordingThread: Thread? = null
private var recordedData: ByteArrayOutputStream? = null
private var recordingFilePath: String? = null
+ private var snapshotCounter = 0
private var audioTrack: AudioTrack? = null
private var isPlaying = false
companion object {
+ const val TAG = "NativeAudioModule"
const val SAMPLE_RATE = 16000
const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO
const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT
@@ -70,7 +73,14 @@ class NativeAudioModule(reactContext: ReactApplicationContext) : ReactContextBas
}
recordedData = ByteArrayOutputStream()
+ snapshotCounter = 0
isRecording = true
+
+ // Create the recording WAV file path upfront
+ val tempFile = File(reactApplicationContext.cacheDir, "recording_live.wav")
+ recordingFilePath = tempFile.absolutePath
+ Log.d(TAG, "Recording will be saved to: $recordingFilePath")
+
audioRecord?.startRecording()
// Start recording thread
@@ -88,7 +98,9 @@ class NativeAudioModule(reactContext: ReactApplicationContext) : ReactContextBas
val result = Arguments.createMap().apply {
putString("status", "recording")
+ putString("path", recordingFilePath)
}
+ Log.d(TAG, "Recording started, path: $recordingFilePath")
promise.resolve(result)
} catch (e: Exception) {
@@ -96,6 +108,121 @@ class NativeAudioModule(reactContext: ReactApplicationContext) : ReactContextBas
}
}
+ /**
+ * Create a snapshot WAV file from the current recorded audio data.
+ * This allows mid-recording transcription without stopping the recording.
+ * Each call writes the accumulated audio so far to a new temp file.
+ */
+ @ReactMethod
+ fun getRecordingSnapshot(promise: Promise) {
+ if (!isRecording || recordedData == null) {
+ promise.reject("NOT_RECORDING", "No recording in progress")
+ return
+ }
+
+ try {
+ val pcmData = synchronized(recordedData!!) {
+ recordedData?.toByteArray() ?: ByteArray(0)
+ }
+
+ if (pcmData.isEmpty()) {
+ Log.d(TAG, "Snapshot: No audio data yet")
+ val result = Arguments.createMap().apply {
+ putString("path", "")
+ putInt("fileSize", 0)
+ }
+ promise.resolve(result)
+ return
+ }
+
+ // Create WAV from accumulated PCM data
+ val wavData = createWavFromPcm(pcmData, SAMPLE_RATE, 1, 16)
+
+ // Write to a snapshot file (use counter to avoid file locking issues)
+ snapshotCounter++
+ val snapshotFile = File(reactApplicationContext.cacheDir, "recording_snapshot_${snapshotCounter % 2}.wav")
+ FileOutputStream(snapshotFile).use { it.write(wavData) }
+
+ Log.d(TAG, "Snapshot created: ${snapshotFile.absolutePath}, size: ${wavData.size} bytes, PCM: ${pcmData.size} bytes")
+
+ val result = Arguments.createMap().apply {
+ putString("path", snapshotFile.absolutePath)
+ putInt("fileSize", wavData.size)
+ putDouble("durationMs", (pcmData.size.toDouble() / (SAMPLE_RATE * 2)) * 1000)
+ }
+ promise.resolve(result)
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to create snapshot: ${e.message}", e)
+ promise.reject("SNAPSHOT_ERROR", "Failed to create recording snapshot: ${e.message}", e)
+ }
+ }
+
+ /**
+ * Create a snapshot WAV file containing ONLY the last N milliseconds of audio.
+ * This enables fast, chunk-based transcription instead of processing the
+ * endlessly growing full audio buffer.
+ */
+ @ReactMethod
+ fun getRecentAudioSnapshot(durationMs: Double, promise: Promise) {
+ if (!isRecording || recordedData == null) {
+ promise.reject("NOT_RECORDING", "No recording in progress")
+ return
+ }
+
+ try {
+ val pcmData = synchronized(recordedData!!) {
+ recordedData?.toByteArray() ?: ByteArray(0)
+ }
+
+ if (pcmData.isEmpty()) {
+ Log.d(TAG, "Recent Snapshot: No audio data yet")
+ val result = Arguments.createMap().apply {
+ putString("path", "")
+ putInt("fileSize", 0)
+ }
+ promise.resolve(result)
+ return
+ }
+
+ // Calculate bytes needed for the requested duration.
+ // 1 sec = SAMPLE_RATE samples = SAMPLE_RATE * 2 bytes (16-bit Mono)
+ val bytesRequired = ((durationMs / 1000.0) * SAMPLE_RATE * 2).toInt()
+
+ // Ensure we use an even number of bytes to avoid splitting a 16-bit sample
+ val safeBytesRequired = bytesRequired - (bytesRequired % 2)
+
+ // Extract only the recent chunk from the end of the PCM data
+ val extractSize = minOf(safeBytesRequired, pcmData.size)
+ val startIndex = pcmData.size - extractSize
+
+ // Create a new ByteArray containing only the recent data
+ val recentPcmData = pcmData.copyOfRange(startIndex, pcmData.size)
+
+ Log.d(TAG, "Recent Snapshot: Extracting last ${durationMs}ms -> ${extractSize} bytes out of ${pcmData.size} total")
+
+ // Create WAV from the recent PCM data only
+ val wavData = createWavFromPcm(recentPcmData, SAMPLE_RATE, 1, 16)
+
+ // Write to a snapshot file
+ snapshotCounter++
+ // Use _recent prefix to keep separate from full snapshots
+ val snapshotFile = File(reactApplicationContext.cacheDir, "recording_snapshot_recent_${snapshotCounter % 2}.wav")
+ FileOutputStream(snapshotFile).use { it.write(wavData) }
+
+ val result = Arguments.createMap().apply {
+ putString("path", snapshotFile.absolutePath)
+ putInt("fileSize", wavData.size)
+ putDouble("durationMs", (recentPcmData.size.toDouble() / (SAMPLE_RATE * 2)) * 1000)
+ }
+ promise.resolve(result)
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to create recent snapshot: ${e.message}", e)
+ promise.reject("SNAPSHOT_ERROR", "Failed to create recent recording snapshot: ${e.message}", e)
+ }
+ }
+
@ReactMethod
fun stopRecording(promise: Promise) {
if (!isRecording) {
@@ -120,11 +247,16 @@ class NativeAudioModule(reactContext: ReactApplicationContext) : ReactContextBas
val wavData = createWavFromPcm(pcmData, SAMPLE_RATE, 1, 16)
val base64Audio = Base64.encodeToString(wavData, Base64.NO_WRAP)
- // Save to temp file
+ // Save to final file
val tempFile = File(reactApplicationContext.cacheDir, "recording_${System.currentTimeMillis()}.wav")
FileOutputStream(tempFile).use { it.write(wavData) }
recordingFilePath = tempFile.absolutePath
+ // Clean up snapshot files
+ cleanupSnapshots()
+
+ Log.d(TAG, "Recording stopped, saved to: $recordingFilePath, size: ${wavData.size} bytes")
+
val result = Arguments.createMap().apply {
putString("status", "stopped")
putString("path", recordingFilePath)
@@ -150,6 +282,9 @@ class NativeAudioModule(reactContext: ReactApplicationContext) : ReactContextBas
recordedData = null
recordingFilePath = null
+ // Clean up snapshot files
+ cleanupSnapshots()
+
promise.resolve(true)
} catch (e: Exception) {
// Ignore errors during cancel
@@ -157,10 +292,30 @@ class NativeAudioModule(reactContext: ReactApplicationContext) : ReactContextBas
}
}
+ private fun cleanupSnapshots() {
+ try {
+ for (i in 0..1) {
+ val snapshotFile = File(reactApplicationContext.cacheDir, "recording_snapshot_$i.wav")
+ if (snapshotFile.exists()) {
+ snapshotFile.delete()
+ }
+ }
+ val liveFile = File(reactApplicationContext.cacheDir, "recording_live.wav")
+ if (liveFile.exists()) {
+ liveFile.delete()
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to cleanup snapshots: ${e.message}")
+ }
+ }
+
@ReactMethod
fun getAudioLevel(promise: Promise) {
if (!isRecording || audioRecord == null) {
- promise.resolve(0.0)
+ val result = Arguments.createMap().apply {
+ putDouble("level", 0.0)
+ }
+ promise.resolve(result)
return
}
@@ -181,9 +336,15 @@ class NativeAudioModule(reactContext: ReactApplicationContext) : ReactContextBas
}
kotlin.math.sqrt(sum / (lastChunk.size / 2)) / 32768.0
}
- promise.resolve(level)
+ val result = Arguments.createMap().apply {
+ putDouble("level", level)
+ }
+ promise.resolve(result)
} catch (e: Exception) {
- promise.resolve(0.0)
+ val result = Arguments.createMap().apply {
+ putDouble("level", 0.0)
+ }
+ promise.resolve(result)
}
}
diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml
deleted file mode 100644
index 5358249..0000000
--- a/android/app/src/main/res/drawable/ic_launcher_foreground.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
index 5ed0a2d..0c0a45c 100644
--- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -1,5 +1,5 @@
-
-
+
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
index 5ed0a2d..46a0ddb 100644
--- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -1,5 +1,5 @@
-
-
+
+
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..b6bf71d
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.xml b/android/app/src/main/res/mipmap-hdpi/ic_launcher.xml
deleted file mode 100644
index c77a778..0000000
--- a/android/app/src/main/res/mipmap-hdpi/ic_launcher.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..b6bf71d
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml
deleted file mode 100644
index c77a778..0000000
--- a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..5306e83
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.xml b/android/app/src/main/res/mipmap-mdpi/ic_launcher.xml
deleted file mode 100644
index c77a778..0000000
--- a/android/app/src/main/res/mipmap-mdpi/ic_launcher.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..5306e83
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.xml b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.xml
deleted file mode 100644
index c77a778..0000000
--- a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..705e97b
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.xml b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.xml
deleted file mode 100644
index c77a778..0000000
--- a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..705e97b
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.xml b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.xml
deleted file mode 100644
index c77a778..0000000
--- a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..52772c2
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml
deleted file mode 100644
index c77a778..0000000
--- a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..52772c2
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.xml b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.xml
deleted file mode 100644
index c77a778..0000000
--- a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..8886f4c
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml
deleted file mode 100644
index c77a778..0000000
--- a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..8886f4c
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.xml b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.xml
deleted file mode 100644
index c77a778..0000000
--- a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
index a5a754f..280bdbc 100644
--- a/android/app/src/main/res/values/colors.xml
+++ b/android/app/src/main/res/values/colors.xml
@@ -1,5 +1,5 @@
- #1A1F2C
+ #FFFFFF
#FFFFFF
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index e4b971f..11abb4c 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -1,3 +1,3 @@
- RunAnywhere Starter
+ Latent
diff --git a/android/link-assets-manifest.json b/android/link-assets-manifest.json
new file mode 100644
index 0000000..117befc
--- /dev/null
+++ b/android/link-assets-manifest.json
@@ -0,0 +1,9 @@
+{
+ "migIndex": 1,
+ "data": [
+ {
+ "path": "assets/fonts/Lora-Regular.ttf",
+ "sha1": "2b770fae41c7e1ee781ed458bd3df94e3dc0fce3"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/assets/app.icns b/assets/app.icns
new file mode 100644
index 0000000..fa94e8f
Binary files /dev/null and b/assets/app.icns differ
diff --git a/assets/app.ico b/assets/app.ico
new file mode 100644
index 0000000..fa1d1e9
Binary files /dev/null and b/assets/app.ico differ
diff --git a/assets/favicon-120.png b/assets/favicon-120.png
new file mode 100644
index 0000000..e569de5
Binary files /dev/null and b/assets/favicon-120.png differ
diff --git a/assets/favicon-128.png b/assets/favicon-128.png
new file mode 100644
index 0000000..5d9a163
Binary files /dev/null and b/assets/favicon-128.png differ
diff --git a/assets/favicon-144.png b/assets/favicon-144.png
new file mode 100644
index 0000000..dbe3bf0
Binary files /dev/null and b/assets/favicon-144.png differ
diff --git a/assets/favicon-152.png b/assets/favicon-152.png
new file mode 100644
index 0000000..18226e1
Binary files /dev/null and b/assets/favicon-152.png differ
diff --git a/assets/favicon-195.png b/assets/favicon-195.png
new file mode 100644
index 0000000..1b4003a
Binary files /dev/null and b/assets/favicon-195.png differ
diff --git a/assets/favicon-228.png b/assets/favicon-228.png
new file mode 100644
index 0000000..a10a976
Binary files /dev/null and b/assets/favicon-228.png differ
diff --git a/assets/favicon-32.png b/assets/favicon-32.png
new file mode 100644
index 0000000..892944e
Binary files /dev/null and b/assets/favicon-32.png differ
diff --git a/assets/favicon-48.png b/assets/favicon-48.png
new file mode 100644
index 0000000..3a81a9e
Binary files /dev/null and b/assets/favicon-48.png differ
diff --git a/assets/favicon-57.png b/assets/favicon-57.png
new file mode 100644
index 0000000..f2a89da
Binary files /dev/null and b/assets/favicon-57.png differ
diff --git a/assets/favicon-72.png b/assets/favicon-72.png
new file mode 100644
index 0000000..692fccb
Binary files /dev/null and b/assets/favicon-72.png differ
diff --git a/assets/favicon-96.png b/assets/favicon-96.png
new file mode 100644
index 0000000..61a62f7
Binary files /dev/null and b/assets/favicon-96.png differ
diff --git a/assets/favicon.ico b/assets/favicon.ico
new file mode 100644
index 0000000..7b5a5b6
Binary files /dev/null and b/assets/favicon.ico differ
diff --git a/assets/fonts/Lora-Regular.ttf b/assets/fonts/Lora-Regular.ttf
new file mode 100644
index 0000000..ee1914c
Binary files /dev/null and b/assets/fonts/Lora-Regular.ttf differ
diff --git a/assets/icon.png b/assets/icon.png
new file mode 100644
index 0000000..1676225
Binary files /dev/null and b/assets/icon.png differ
diff --git a/docs/MULTI_LANGUAGE_FEATURE.md b/docs/MULTI_LANGUAGE_FEATURE.md
new file mode 100644
index 0000000..8a39e99
--- /dev/null
+++ b/docs/MULTI_LANGUAGE_FEATURE.md
@@ -0,0 +1,354 @@
+# Multi-Language STT/TTS Feature
+
+This document describes the multi-language Speech-to-Text (STT) and Text-to-Speech (TTS) feature added to the RunAnywhere React Native Starter App.
+
+## Overview
+
+The app now supports multiple languages for both speech recognition and voice synthesis, allowing users to:
+
+- Select their preferred language for speech-to-text transcription
+- Choose from available TTS voices in different languages
+- Switch languages dynamically without reloading the app
+
+## Features Implemented
+
+### 1. Language Configuration System (`src/config/languages.ts`)
+
+A centralized configuration file that defines:
+
+- **Supported languages**: English, Spanish, French, German, Chinese, Japanese
+- **Language metadata**: Name, native name, flag emoji, language code
+- **Model mappings**: STT and TTS model IDs for each language
+- **Helper functions**: `getLanguageByCode()`, `hasSTTSupport()`, `hasTTSSupport()`
+
+```typescript
+export interface LanguageConfig {
+ code: string; // ISO language code (e.g., 'en', 'es')
+ name: string; // English name
+ nativeName: string; // Native language name
+ flag: string; // Flag emoji
+ sttModelId?: string; // STT model identifier
+ ttsModelId?: string; // TTS model identifier
+ sttModelUrl?: string; // Model download URL
+ ttsModelUrl?: string; // Model download URL
+}
+```
+
+### 2. LanguageSelector Component (`src/components/LanguageSelector.tsx`)
+
+A beautiful modal component for language selection featuring:
+
+- **Filterable list**: Can filter by STT/TTS support
+- **Visual indicators**: Shows which languages support STT and TTS
+- **Clean UI**: Modal with gradient header and smooth animations
+- **Accessibility**: Touch-friendly, large tap targets
+
+**Usage:**
+
+```tsx
+ setShowLanguageSelector(false)}
+ filterSTT={true} // Only show languages with STT support
+/>
+```
+
+### 3. VoiceSelector Component (`src/components/VoiceSelector.tsx`)
+
+A modal for selecting TTS voices with:
+
+- **Voice metadata display**: Name, language, quality, gender
+- **Type indicators**: Neural vs System voices
+- **Download status**: Shows if voice requires download
+- **Language filtering**: Can filter voices by language code
+- **Fallback handling**: Shows mock voices if SDK API unavailable
+
+**Usage:**
+
+```tsx
+ setShowVoiceSelector(false)}
+ languageFilter={selectedLanguage.code}
+/>
+```
+
+### 4. Enhanced SpeechToTextScreen
+
+**New features:**
+
+- Language selection button at the top
+- Displays selected language with flag emoji
+- Passes language code to `RunAnywhere.transcribe()` API
+- Filters language selector to only show languages with STT support
+
+**Code changes:**
+
+```typescript
+// Language selector UI
+ setShowLanguageSelector(true)}
+>
+ {selectedLanguage.flag}
+
+ Language
+ {selectedLanguage.name}
+
+
+
+// Transcribe with selected language
+const result = await RunAnywhere.transcribe(audioBase64, {
+ sampleRate: 16000,
+ language: selectedLanguage.code,
+});
+```
+
+### 5. Enhanced TextToSpeechScreen
+
+**New features:**
+
+- Dual selection buttons: Language and Voice
+- Language selection changes available voices
+- Voice selection filtered by selected language
+- Selected voice ID passed to synthesis API
+
+**Code changes:**
+
+```typescript
+// Language & Voice selectors
+
+ setShowLanguageSelector(true)}>
+ {/* Language button */}
+
+ setShowVoiceSelector(true)}>
+ {/* Voice button */}
+
+
+
+// Synthesize with selected voice
+const result = await RunAnywhere.synthesize(text, {
+ voice: selectedVoice?.id || 'default',
+ rate: speechRate,
+ pitch: 1.0,
+ volume: 1.0,
+});
+```
+
+## Supported Languages
+
+| Language | Code | STT Support | TTS Support |
+| ------------ | ---- | --------------- | --------------- |
+| English (US) | `en` | â
Yes | â
Yes |
+| Spanish | `es` | â ī¸ Model needed | â ī¸ Model needed |
+| French | `fr` | â ī¸ Model needed | â ī¸ Model needed |
+| German | `de` | â ī¸ Model needed | â ī¸ Model needed |
+| Chinese | `zh` | â ī¸ Model needed | â No |
+| Japanese | `ja` | â ī¸ Model needed | â No |
+
+**Note:** Currently only English models are downloaded by default. To add support for other languages, you need to:
+
+1. Download the appropriate STT/TTS models for that language
+2. Register them in `ModelService.tsx`
+3. Update the model URLs in `src/config/languages.ts`
+
+## How It Works
+
+### Language Selection Flow
+
+1. User taps language button
+2. `LanguageSelector` modal opens
+3. User selects a language
+4. Selected language is saved to component state
+5. Language code is passed to STT/TTS APIs
+6. For TTS: Available voices are filtered by language
+
+### Model Management
+
+The app uses RunAnywhere SDK's model management system:
+
+```typescript
+// Per RunAnywhere docs: https://docs.runanywhere.ai/react-native/stt/options
+
+// STT with language option
+await RunAnywhere.transcribe(audioData, {
+ language: 'es', // Spanish
+ sampleRate: 16000,
+});
+
+// TTS with voice option
+await RunAnywhere.synthesize(text, {
+ voice: 'en_US-lessac-medium', // English voice
+ rate: 1.0,
+});
+```
+
+## Adding New Languages
+
+To add support for a new language:
+
+### 1. Add language to configuration
+
+Edit `src/config/languages.ts`:
+
+```typescript
+{
+ code: 'it',
+ name: 'Italian',
+ nativeName: 'Italiano',
+ flag: 'đŽđš',
+ sttModelId: 'sherpa-onnx-whisper-tiny-it',
+ ttsModelId: 'vits-piper-it_IT-medium',
+ sttModelUrl: 'https://github.com/.../whisper-tiny-it.tar.gz',
+ ttsModelUrl: 'https://github.com/.../piper-it-IT.tar.gz',
+}
+```
+
+### 2. Register models in ModelService
+
+Edit `src/services/ModelService.tsx`:
+
+```typescript
+// Add model registration
+await ONNX.addModel({
+ id: 'sherpa-onnx-whisper-tiny-it',
+ name: 'Sherpa Whisper Tiny (Italian)',
+ url: 'https://github.com/.../whisper-tiny-it.tar.gz',
+ modality: ModelCategory.SpeechRecognition,
+ artifactType: ModelArtifactType.TarGzArchive,
+ memoryRequirement: 75_000_000,
+});
+```
+
+### 3. Download and load the models
+
+Models can be downloaded:
+
+- **Automatically**: When user first accesses STT/TTS screens
+- **Manually**: Via model management in your app settings
+- **Programmatically**: Using `RunAnywhere.downloadModel()`
+
+## Architecture
+
+```
+src/
+âââ config/
+â âââ languages.ts # Language definitions & helpers
+â âââ index.ts # Config exports
+âââ components/
+â âââ LanguageSelector.tsx # Language picker modal
+â âââ VoiceSelector.tsx # Voice picker modal
+â âââ index.ts # Component exports
+âââ screens/
+ âââ SpeechToTextScreen.tsx # STT with language selection
+ âââ TextToSpeechScreen.tsx # TTS with voice selection
+```
+
+## Performance Considerations
+
+- **Model size**: Each language model is 50-100MB
+- **Memory usage**: Only one model loaded at a time
+- **Switching languages**: Requires model unload/reload
+- **Download time**: 30-60 seconds per model on average connection
+
+## Future Enhancements
+
+Potential improvements for the multi-language feature:
+
+1. **Model pre-loading**: Download popular language models during onboarding
+2. **Language detection**: Auto-detect spoken language in STT
+3. **Voice preview**: Let users hear voice samples before selection
+4. **Model caching**: Keep recently used models in memory
+5. **Compression**: Use smaller quantized models for faster downloads
+6. **Language packs**: Bundle STT+TTS models together
+7. **Offline indicator**: Show which languages work offline
+8. **Translation**: Add real-time translation between languages
+
+## API Reference
+
+### LanguageConfig
+
+```typescript
+interface LanguageConfig {
+ code: string;
+ name: string;
+ nativeName: string;
+ flag: string;
+ sttModelId?: string;
+ ttsModelId?: string;
+ sttModelUrl?: string;
+ ttsModelUrl?: string;
+}
+```
+
+### TTSVoiceInfo
+
+```typescript
+interface TTSVoiceInfo {
+ id: string;
+ name: string;
+ language: string;
+ type: 'neural' | 'system';
+ quality: 'low' | 'medium' | 'high';
+ gender?: 'male' | 'female' | 'neutral';
+ requiresDownload: boolean;
+ isAvailable: boolean;
+}
+```
+
+## Testing
+
+To test the multi-language feature:
+
+1. **Language Selection**:
+ - Open STT or TTS screen
+ - Tap language selector button
+ - Verify languages show with correct flags and names
+ - Select a language and verify it's displayed
+
+2. **Voice Selection** (TTS screen):
+ - Select a language first
+ - Tap voice selector button
+ - Verify only voices for selected language are shown
+ - Select a voice and verify synthesis uses it
+
+3. **Speech Recognition**:
+ - Select a non-English language
+ - Record audio in that language
+ - Verify transcription accuracy
+
+4. **Voice Synthesis**:
+ - Select a non-English voice
+ - Enter text in that language
+ - Verify audio is generated in correct language/voice
+
+## Troubleshooting
+
+### "No voices available for language"
+
+- The selected language may not have TTS models downloaded
+- Check model availability in `src/config/languages.ts`
+- Download required TTS model for that language
+
+### STT not recognizing speech
+
+- Ensure correct language is selected
+- Verify STT model for that language is downloaded and loaded
+- Check microphone permissions
+
+### Wrong voice used for synthesis
+
+- Verify voice is selected before synthesis
+- Check that selected voice matches language
+- Fallback to 'default' voice if none selected
+
+## Resources
+
+- [RunAnywhere STT Options Docs](https://docs.runanywhere.ai/react-native/stt/options)
+- [RunAnywhere TTS Voices Docs](https://docs.runanywhere.ai/react-native/tts/voices)
+- [Whisper Model Languages](https://github.com/openai/whisper#available-models-and-languages)
+- [Piper TTS Voices](https://github.com/rhasspy/piper/blob/master/VOICES.md)
diff --git a/ios/RunAnywhereStarter.xcodeproj/project.pbxproj b/ios/RunAnywhereStarter.xcodeproj/project.pbxproj
index c6529e2..d7f7d5e 100644
--- a/ios/RunAnywhereStarter.xcodeproj/project.pbxproj
+++ b/ios/RunAnywhereStarter.xcodeproj/project.pbxproj
@@ -17,6 +17,7 @@
95FAA6247F992A3A1B818127 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; };
NATIVE_AUDIO_OBJC /* NativeAudioModule.m in Sources */ = {isa = PBXBuildFile; fileRef = NATIVE_AUDIO_OBJC_REF /* NativeAudioModule.m */; };
NATIVE_AUDIO_SWIFT /* NativeAudioModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = NATIVE_AUDIO_SWIFT_REF /* NativeAudioModule.swift */; };
+ D67CA02AB17A45E4B7A343BC /* Lora-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = E8D10F655D094F99B944453C /* Lora-Regular.ttf */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -51,6 +52,7 @@
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
NATIVE_AUDIO_OBJC_REF /* NativeAudioModule.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = NativeAudioModule.m; path = RunAnywhereStarter/NativeAudioModule.m; sourceTree = ""; };
NATIVE_AUDIO_SWIFT_REF /* NativeAudioModule.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = NativeAudioModule.swift; path = RunAnywhereStarter/NativeAudioModule.swift; sourceTree = ""; };
+ E8D10F655D094F99B944453C /* Lora-Regular.ttf */ = {isa = PBXFileReference; name = "Lora-Regular.ttf"; path = "../assets/fonts/Lora-Regular.ttf"; sourceTree = ""; fileEncoding = undefined; lastKnownFileType = unknown; explicitFileType = undefined; includeInIndex = 0; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -133,6 +135,7 @@
83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */,
BBD78D7AC51CEA395F1C20DB /* Pods */,
+ 612D5D6699364B9B9514468C /* Resources */,
);
indentWidth = 2;
sourceTree = "";
@@ -159,6 +162,15 @@
path = Pods;
sourceTree = "";
};
+ 612D5D6699364B9B9514468C /* Resources */ = {
+ isa = "PBXGroup";
+ children = (
+ E8D10F655D094F99B944453C /* Lora-Regular.ttf */,
+ );
+ name = Resources;
+ sourceTree = "";
+ path = "";
+ };
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -255,6 +267,7 @@
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
95FAA6247F992A3A1B818127 /* PrivacyInfo.xcprivacy in Resources */,
+ D67CA02AB17A45E4B7A343BC /* Lora-Regular.ttf in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
diff --git a/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/1024x1024.png b/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/1024x1024.png
new file mode 100644
index 0000000..75ecb18
Binary files /dev/null and b/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/1024x1024.png differ
diff --git a/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/20x20@2x.png b/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/20x20@2x.png
new file mode 100644
index 0000000..366f2ee
Binary files /dev/null and b/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/20x20@2x.png differ
diff --git a/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/20x20@3x.png b/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/20x20@3x.png
new file mode 100644
index 0000000..13057b0
Binary files /dev/null and b/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/20x20@3x.png differ
diff --git a/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/29x29@2x.png b/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/29x29@2x.png
new file mode 100644
index 0000000..2c74156
Binary files /dev/null and b/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/29x29@2x.png differ
diff --git a/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/29x29@3x.png b/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/29x29@3x.png
new file mode 100644
index 0000000..6c87eb4
Binary files /dev/null and b/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/29x29@3x.png differ
diff --git a/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/40x40@2x.png b/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/40x40@2x.png
new file mode 100644
index 0000000..91c9821
Binary files /dev/null and b/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/40x40@2x.png differ
diff --git a/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/40x40@3x.png b/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/40x40@3x.png
new file mode 100644
index 0000000..54b7e35
Binary files /dev/null and b/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/40x40@3x.png differ
diff --git a/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/60x60@2x.png b/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/60x60@2x.png
new file mode 100644
index 0000000..54b7e35
Binary files /dev/null and b/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/60x60@2x.png differ
diff --git a/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/60x60@3x.png b/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/60x60@3x.png
new file mode 100644
index 0000000..1482112
Binary files /dev/null and b/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/60x60@3x.png differ
diff --git a/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/Contents.json b/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/Contents.json
index 8121323..9cd008a 100644
--- a/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/Contents.json
+++ b/ios/RunAnywhereStarter/Images.xcassets/AppIcon.appiconset/Contents.json
@@ -3,47 +3,56 @@
{
"idiom" : "iphone",
"scale" : "2x",
- "size" : "20x20"
+ "size" : "20x20",
+ "filename": "20x20@2x.png"
},
{
"idiom" : "iphone",
"scale" : "3x",
- "size" : "20x20"
+ "size" : "20x20",
+ "filename": "20x20@3x.png"
},
{
"idiom" : "iphone",
"scale" : "2x",
- "size" : "29x29"
+ "size" : "29x29",
+ "filename": "29x29@2x.png"
},
{
"idiom" : "iphone",
"scale" : "3x",
- "size" : "29x29"
+ "size" : "29x29",
+ "filename": "29x29@3x.png"
},
{
"idiom" : "iphone",
"scale" : "2x",
- "size" : "40x40"
+ "size" : "40x40",
+ "filename": "40x40@2x.png"
},
{
"idiom" : "iphone",
"scale" : "3x",
- "size" : "40x40"
+ "size" : "40x40",
+ "filename": "40x40@3x.png"
},
{
"idiom" : "iphone",
"scale" : "2x",
- "size" : "60x60"
+ "size" : "60x60",
+ "filename": "60x60@2x.png"
},
{
"idiom" : "iphone",
"scale" : "3x",
- "size" : "60x60"
+ "size" : "60x60",
+ "filename": "60x60@3x.png"
},
{
"idiom" : "ios-marketing",
"scale" : "1x",
- "size" : "1024x1024"
+ "size" : "1024x1024",
+ "filename": "1024x1024.png"
}
],
"info" : {
diff --git a/ios/RunAnywhereStarter/Info.plist b/ios/RunAnywhereStarter/Info.plist
index 89457f4..ceecc26 100644
--- a/ios/RunAnywhereStarter/Info.plist
+++ b/ios/RunAnywhereStarter/Info.plist
@@ -46,7 +46,7 @@
NSLocalNetworkUsageDescription
This app uses the local network to connect to Metro bundler during development
NSLocationWhenInUseUsageDescription
-
+
NSMicrophoneUsageDescription
This app needs microphone access for speech recognition and voice agent features
NSSpeechRecognitionUsageDescription
@@ -67,5 +67,9 @@
UIViewControllerBasedStatusBarAppearance
+ UIAppFonts
+
+ Lora-Regular.ttf
+
diff --git a/ios/link-assets-manifest.json b/ios/link-assets-manifest.json
new file mode 100644
index 0000000..117befc
--- /dev/null
+++ b/ios/link-assets-manifest.json
@@ -0,0 +1,9 @@
+{
+ "migIndex": 1,
+ "data": [
+ {
+ "path": "assets/fonts/Lora-Regular.ttf",
+ "sha1": "2b770fae41c7e1ee781ed458bd3df94e3dc0fce3"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 2125746..c461acb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,13 +1,14 @@
{
- "name": "runanywhere-starter-app",
+ "name": "latent",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "runanywhere-starter-app",
+ "name": "latent",
"version": "1.0.0",
"dependencies": {
+ "@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/native": "^7.1.24",
"@react-navigation/stack": "^7.6.16",
"@runanywhere/core": "^0.18.1",
@@ -21,7 +22,8 @@
"react-native-live-audio-stream": "^1.1.1",
"react-native-nitro-modules": "^0.31.10",
"react-native-safe-area-context": "~5.6.2",
- "react-native-sound": "^0.13.0"
+ "react-native-sound": "^0.13.0",
+ "react-native-svg": "^15.15.3"
},
"devDependencies": {
"@babel/core": "^7.25.2",
@@ -72,6 +74,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -103,6 +106,7 @@
"integrity": "sha512-QGmsKi2PBO/MHSQk+AAgA9R6OHQr+VqnniFE0eMWZcVcfBZoA2dKn2hUsl3Csg/Plt9opRUWdY7//VXsrIlEiA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1",
"eslint-visitor-keys": "^2.1.0",
@@ -2526,12 +2530,25 @@
"node": ">= 8"
}
},
+ "node_modules/@react-native-async-storage/async-storage": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
+ "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
+ "license": "MIT",
+ "dependencies": {
+ "merge-options": "^3.0.4"
+ },
+ "peerDependencies": {
+ "react-native": "^0.0.0-0 || >=0.65 <1.0"
+ }
+ },
"node_modules/@react-native-community/cli": {
"version": "20.1.1",
"resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-20.1.1.tgz",
"integrity": "sha512-aLPUx43+WSeTOaUepR2FBD5a1V0OAZ1QB2DOlRlW4fOEjtBXgv40eM/ho8g3WCvAOKfPvTvx4fZdcuovTyV81Q==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@react-native-community/cli-clean": "20.1.1",
"@react-native-community/cli-config": "20.1.1",
@@ -3081,6 +3098,7 @@
"integrity": "sha512-1rjYZf62fCm6QAinHmRAKnJxIypX0VF/zBPd0qWvWABMZugrS0eACuIbk9Wk0StBod4yL8KnwEJyg77ak8xYzQ==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@react-native/js-polyfills": "0.83.1",
"@react-native/metro-babel-transformer": "0.83.1",
@@ -3151,6 +3169,7 @@
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.28.tgz",
"integrity": "sha512-d1QDn+KNHfHGt3UIwOZvupvdsDdiHYZBEj7+wL2yDVo3tMezamYy60H9s3EnNVE1Ae1ty0trc7F2OKqo/RmsdQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@react-navigation/core": "^7.14.0",
"escape-string-regexp": "^4.0.0",
@@ -3196,6 +3215,7 @@
"resolved": "https://registry.npmjs.org/@runanywhere/core/-/core-0.18.1.tgz",
"integrity": "sha512-M4epS8GdesQ0g4CUCvqnVA/YXVOBNwOsQUBh7mbEfl3oFrjzJdLzR1UP5X9an8SDqXMy87+Uap3o0dEKmMWwEA==",
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"react": ">=18.0.0",
"react-native": ">=0.74.0",
@@ -3394,6 +3414,7 @@
"integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -3446,6 +3467,7 @@
"integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.55.0",
@@ -3475,6 +3497,7 @@
"integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.55.0",
"@typescript-eslint/types": "8.55.0",
@@ -3739,6 +3762,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4310,6 +4334,12 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/boolbase": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+ "license": "ISC"
+ },
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
@@ -4351,6 +4381,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -4856,6 +4887,56 @@
"node": ">= 8"
}
},
+ "node_modules/css-select": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
+ "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-what": "^6.1.0",
+ "domhandler": "^5.0.2",
+ "domutils": "^3.0.1",
+ "nth-check": "^2.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/css-tree": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
+ "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.0.14",
+ "source-map": "^0.6.1"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/css-tree/node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/css-what": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
+ "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">= 6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -5058,6 +5139,61 @@
"node": ">=6.0.0"
}
},
+ "node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -5100,6 +5236,18 @@
"node": ">= 0.8"
}
},
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@@ -5371,6 +5519,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -7054,6 +7203,15 @@
"node": ">=8"
}
},
+ "node_modules/is-plain-obj": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
+ "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -7925,6 +8083,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/mdn-data": {
+ "version": "2.0.14",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
+ "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
+ "license": "CC0-1.0"
+ },
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -7941,6 +8105,18 @@
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
+ "node_modules/merge-options": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
+ "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-plain-obj": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -8441,6 +8617,18 @@
"node": ">=8"
}
},
+ "node_modules/nth-check": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+ "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
"node_modules/nullthrows": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
@@ -8856,6 +9044,7 @@
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -9044,6 +9233,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -9084,7 +9274,6 @@
"resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz",
"integrity": "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=10"
},
@@ -9103,6 +9292,7 @@
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.83.1.tgz",
"integrity": "sha512-mL1q5HPq5cWseVhWRLl+Fwvi5z1UO+3vGOpjr+sHFwcUletPRZ5Kv+d0tUfqHmvi73/53NjlQqX1Pyn4GguUfA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jest/create-cache-key-function": "^29.7.0",
"@react-native/assets-registry": "0.83.1",
@@ -9161,6 +9351,7 @@
"resolved": "https://registry.npmjs.org/react-native-fs/-/react-native-fs-2.20.0.tgz",
"integrity": "sha512-VkTBzs7fIDUiy/XajOSNk0XazFE9l+QlMAce7lGuebZcag5CnjszB+u4BdqzwaQOdcYb5wsJIsqq4kxInIRpJQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"base-64": "^0.1.0",
"utf8": "^3.0.0"
@@ -9180,6 +9371,7 @@
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.30.0.tgz",
"integrity": "sha512-5YsnKHGa0X9C8lb5oCnKm0fLUPM6CRduvUUw2Bav4RIj/C3HcFh4RIUnF8wgG6JQWCL1//gRx4v+LVWgcIQdGA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@egjs/hammerjs": "^2.0.17",
"hoist-non-react-statics": "^3.3.0",
@@ -9212,6 +9404,7 @@
"integrity": "sha512-hcvjTu9YJE9fMmnAUvhG8CxvYLpOuMQ/2eyi/S6GyrecezF6Rmk/uRQEL6v09BRFWA/xRVZNQVulQPS+2HS3mQ==",
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"react": "*",
"react-native": "*"
@@ -9222,6 +9415,7 @@
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz",
"integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==",
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"react": "*",
"react-native": "*"
@@ -9252,6 +9446,21 @@
"react-native": "*"
}
},
+ "node_modules/react-native-svg": {
+ "version": "15.15.3",
+ "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.3.tgz",
+ "integrity": "sha512-/k4KYwPBLGcx2f5d4FjE+vCScK7QOX14cl2lIASJ28u4slHHtIhL0SZKU7u9qmRBHxTCKPoPBtN6haT1NENJNA==",
+ "license": "MIT",
+ "dependencies": {
+ "css-select": "^5.1.0",
+ "css-tree": "^1.1.3",
+ "warn-once": "0.1.1"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/react-native/node_modules/@react-native/virtualized-lists": {
"version": "0.83.1",
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.83.1.tgz",
@@ -10469,6 +10678,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -10649,6 +10859,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -10854,8 +11065,7 @@
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz",
"integrity": "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/wcwidth": {
"version": "1.0.1",
@@ -11116,6 +11326,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/package.json b/package.json
index 647e846..2b60bb1 100644
--- a/package.json
+++ b/package.json
@@ -1,16 +1,19 @@
{
- "name": "runanywhere-starter-app",
+ "name": "latent",
"version": "1.0.0",
- "description": "A comprehensive starter app demonstrating RunAnywhere SDK capabilities - on-device AI for React Native",
+ "description": "Latent - Offline Meeting Intelligence. Privacy-first negotiation assistant powered by on-device AI.",
"main": "index.js",
"scripts": {
"android": "react-native run-android",
"ios": "react-native run-ios",
"start": "react-native start",
"test": "jest",
- "lint": "eslint . --ext .js,.jsx,.ts,.tsx"
+ "lint": "eslint . --ext .js,.jsx,.ts,.tsx",
+ "check-models": "bash scripts/check-models.sh",
+ "rebuild-android": "cd android && ./gradlew clean && cd .. && react-native run-android"
},
"dependencies": {
+ "@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/native": "^7.1.24",
"@react-navigation/stack": "^7.6.16",
"@runanywhere/core": "^0.18.1",
@@ -24,7 +27,8 @@
"react-native-live-audio-stream": "^1.1.1",
"react-native-nitro-modules": "^0.31.10",
"react-native-safe-area-context": "~5.6.2",
- "react-native-sound": "^0.13.0"
+ "react-native-sound": "^0.13.0",
+ "react-native-svg": "^15.15.3"
},
"devDependencies": {
"@babel/core": "^7.25.2",
diff --git a/react-native.config.js b/react-native.config.js
index 0ebe959..425fc29 100644
--- a/react-native.config.js
+++ b/react-native.config.js
@@ -6,6 +6,7 @@
* the RN 0.83 CLI. Pods must be installed manually: cd ios && pod install && cd ..
*/
module.exports = {
+ assets: ['./assets/fonts/'],
project: {
ios: {
automaticPodsInstallation: false,
diff --git a/scripts/check-models.sh b/scripts/check-models.sh
new file mode 100755
index 0000000..b3c725d
--- /dev/null
+++ b/scripts/check-models.sh
@@ -0,0 +1,53 @@
+#!/bin/bash
+
+echo "đ Checking for STT Model Files..."
+echo ""
+
+MODELS_DIR="android/app/src/main/assets/models"
+
+# Check if directory exists
+if [ ! -d "$MODELS_DIR" ]; then
+ echo "â Models directory does not exist: $MODELS_DIR"
+ echo ""
+ echo "Creating directory..."
+ mkdir -p "$MODELS_DIR"
+ echo "â
Directory created: $MODELS_DIR"
+ echo ""
+ echo "đĨ Next steps:"
+ echo " 1. Download a Whisper model (whisper-tiny recommended)"
+ echo " 2. Place it in: $MODELS_DIR"
+ echo " 3. Update the model name in src/services/SpeechService.ts if needed"
+ echo " 4. Run: npm run rebuild-android"
+ exit 1
+fi
+
+echo "â
Models directory exists: $MODELS_DIR"
+echo ""
+
+# Check for model files
+MODEL_FILES=$(find "$MODELS_DIR" -type f \( -name "*.gguf" -o -name "*.onnx" -o -name "*whisper*" \) 2>/dev/null)
+
+if [ -z "$MODEL_FILES" ]; then
+ echo "â No model files found in $MODELS_DIR"
+ echo ""
+ echo "đĨ Next steps:"
+ echo " 1. Download a Whisper model"
+ echo " 2. Place it in: $MODELS_DIR"
+ echo " 3. See MODEL_SETUP.md for detailed instructions"
+ exit 1
+fi
+
+echo "â
Found model files:"
+echo ""
+echo "$MODEL_FILES" | while read file; do
+ size=$(du -h "$file" | cut -f1)
+ echo " đĻ $(basename "$file") - $size"
+done
+
+echo ""
+echo "đ Model setup looks good!"
+echo ""
+echo "đ Notes:"
+echo " - Model will load on app startup"
+echo " - Check logs for: [SpeechService] STT model loaded"
+echo " - If issues persist, try Debug Mode in Settings"
diff --git a/scripts/patch-rnfs.sh b/scripts/patch-rnfs.sh
new file mode 100755
index 0000000..a508bdd
--- /dev/null
+++ b/scripts/patch-rnfs.sh
@@ -0,0 +1,29 @@
+#!/bin/bash
+# Patch react-native-fs to fix NullPointerException crash on React Native New Architecture
+# Bug: promise.reject(null, ...) crashes because PromiseImpl.reject requires non-null code
+RNFS_MANAGER="node_modules/react-native-fs/android/src/main/java/com/rnfs/RNFSManager.java"
+
+if [ -f "$RNFS_MANAGER" ]; then
+ sed -i.bak 's/promise.reject(null, ex.getMessage())/promise.reject("EUNSPECIFIED", ex.getMessage())/' "$RNFS_MANAGER"
+ rm -f "${RNFS_MANAGER}.bak"
+ echo "â
Patched RNFSManager.java (null code fix)"
+fi
+
+# Patch Downloader.java buffer sizes for faster downloads (8KB -> 256KB)
+DOWNLOADER="node_modules/react-native-fs/android/src/main/java/com/rnfs/Downloader.java"
+
+if [ -f "$DOWNLOADER" ]; then
+ sed -i.bak 's/new BufferedInputStream(connection.getInputStream(), 8 \* 1024)/new BufferedInputStream(connection.getInputStream(), 512 * 1024)/' "$DOWNLOADER"
+ sed -i.bak 's/new byte\[8 \* 1024\]/new byte[256 * 1024]/' "$DOWNLOADER"
+ rm -f "${DOWNLOADER}.bak"
+ echo "â
Patched Downloader.java (256KB buffer)"
+fi
+
+# Patch RunAnywhere SDK to use foreground downloads (Android OEMs throttle background downloads)
+SDK_FILE="node_modules/@runanywhere/core/src/services/FileSystem.ts"
+
+if [ -f "$SDK_FILE" ]; then
+ sed -i.bak 's/background: true,/background: false,/' "$SDK_FILE"
+ rm -f "${SDK_FILE}.bak"
+ echo "â
Patched FileSystem.ts (foreground download)"
+fi
diff --git a/src/App.tsx b/src/App.tsx
index 661a51b..fb2c1dc 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,21 +1,13 @@
import 'react-native-gesture-handler'; // Must be at the top!
-import React, { useEffect } from 'react';
+import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator, TransitionPresets } from '@react-navigation/stack';
import { StatusBar } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
// Note: react-native-screens is shimmed in index.js for iOS New Architecture compatibility
-import { RunAnywhere, SDKEnvironment } from '@runanywhere/core';
-import { ModelServiceProvider, registerDefaultModels } from './services/ModelService';
+import { ModelServiceProvider } from './services/ModelService';
import { AppColors } from './theme';
-import {
- HomeScreen,
- ChatScreen,
- ToolCallingScreen,
- SpeechToTextScreen,
- TextToSpeechScreen,
- VoicePipelineScreen,
-} from './screens';
+import { HomeScreen, LiveSessionScreen, InsightsScreen, SettingsScreen, DisclaimerScreen, OutcomeReplayScreen, PreSessionFormScreen, PreSessionStrategyScreen } from './screens';
import { RootStackParamList } from './navigation/types';
// Using JS-based stack navigator instead of native-stack
@@ -23,87 +15,65 @@ import { RootStackParamList } from './navigation/types';
const Stack = createStackNavigator();
const App: React.FC = () => {
- useEffect(() => {
- // Initialize SDK
- const initializeSDK = async () => {
- try {
- // Initialize RunAnywhere SDK (Development mode doesn't require API key)
- await RunAnywhere.initialize({
- environment: SDKEnvironment.Development,
- });
-
- // Register backends (per docs: https://docs.runanywhere.ai/react-native/quick-start)
- const { LlamaCPP } = await import('@runanywhere/llamacpp');
- const { ONNX } = await import('@runanywhere/onnx');
-
- LlamaCPP.register();
- ONNX.register();
-
- // Register default models
- await registerDefaultModels();
-
- console.log('RunAnywhere SDK initialized successfully');
- } catch (error) {
- console.error('Failed to initialize RunAnywhere SDK:', error);
- }
- };
-
- initializeSDK();
- }, []);
+ // SDK initialization is now handled inside ModelServiceProvider
return (
-
+
+
+
diff --git a/src/ai/BehavioralAnalyticsEngine.ts b/src/ai/BehavioralAnalyticsEngine.ts
new file mode 100644
index 0000000..f4a1ef4
--- /dev/null
+++ b/src/ai/BehavioralAnalyticsEngine.ts
@@ -0,0 +1,76 @@
+import { Session, NegotiationMode } from '../types/session';
+
+export interface BehavioralProfile {
+ archetype: string[];
+ leverageCaptureScore: number;
+ objectionHandlingScore: number;
+ fillerWordCount: number;
+ hesitationMoments: number;
+}
+
+/**
+ * đ§ Strategic Outcome Replayâĸ - Behavioral Pressure Analytics
+ * Consumes the entire transcript text to calculate psychological profile markers
+ * like hesitation, filler spam, and over-talking ratios.
+ */
+export const BehavioralAnalyticsEngine = {
+ analyzeTranscript(session: Session): BehavioralProfile {
+ console.log(`[BehavioralAnalyticsEngine] Profiling Transcript for Session: ${session.id}`);
+
+ if (!session.transcript || session.transcript.length === 0) {
+ return this.getEmptyProfile();
+ }
+
+ const fullText = session.transcript.map(t => t.text).join(' ').toLowerCase();
+
+ // 1. Calculate Filler Words
+ const fillerList = ['um ', 'uh ', 'like ', 'you know', 'basically', 'actually'];
+ let fillerCount = 0;
+ fillerList.forEach(word => {
+ const regex = new RegExp(word, 'gi');
+ const matches = fullText.match(regex);
+ if (matches) fillerCount += matches.length;
+ });
+
+ // 2. Calculate Hesitation Markers
+ const hesitationList = ['i think maybe', 'if possible', 'sort of', 'kind of'];
+ let hesitationCount = 0;
+ hesitationList.forEach(word => {
+ const regex = new RegExp(word, 'gi');
+ const matches = fullText.match(regex);
+ if (matches) hesitationCount += matches.length;
+ });
+
+ // Generate Archetype
+ const archetypes = [];
+ if (fillerCount > 5 || hesitationCount > 3) {
+ archetypes.push("Defensive Under Pressure");
+ archetypes.push("Misses Leverage Signals");
+ } else {
+ archetypes.push("Strong Frame Control");
+ archetypes.push("Direct Communicator");
+ }
+
+ if (fullText.includes("budget") && hesitationCount > 2) {
+ archetypes.push("Accepts Anchors Quickly");
+ }
+
+ return {
+ archetype: archetypes,
+ leverageCaptureScore: Math.round(100 - (hesitationCount * 15) - (fillerCount * 5)) > 0 ? Math.round(100 - (hesitationCount * 15) - (fillerCount * 5)) : 30,
+ objectionHandlingScore: Math.round(100 - (fillerCount * 8)) > 0 ? Math.round(100 - (fillerCount * 8)) : 40,
+ fillerWordCount: fillerCount,
+ hesitationMoments: hesitationCount
+ };
+ },
+
+ getEmptyProfile(): BehavioralProfile {
+ return {
+ archetype: ["Insufficient Data"],
+ leverageCaptureScore: 0,
+ objectionHandlingScore: 0,
+ fillerWordCount: 0,
+ hesitationMoments: 0
+ };
+ }
+};
diff --git a/src/ai/OutcomeReplayEngine.ts b/src/ai/OutcomeReplayEngine.ts
new file mode 100644
index 0000000..29aa225
--- /dev/null
+++ b/src/ai/OutcomeReplayEngine.ts
@@ -0,0 +1,152 @@
+import { Session, NegotiationMode, DetectedPattern, NegotiationPattern } from '../types/session';
+
+export interface TacticalImprovement {
+ originalQuote: string;
+ originalStrengthScore: number;
+ improvedReframing: string;
+ improvedStrengthScore: number;
+ tacticType: NegotiationPattern;
+ delta: number;
+}
+
+export interface PostSessionSummary {
+ whatWorked: string[];
+ signalsOfInterest: string[];
+ hiddenObjections: string[];
+ followUpStrategy: string;
+}
+
+export interface ReplaySimulationResult {
+ sessionId: string;
+ opportunities: TacticalImprovement[];
+ totalDeltaGained: number;
+ postSessionSummary: PostSessionSummary;
+}
+
+/**
+ * đ§ Strategic Outcome Replayâĸ - Counterfactual Simulation Engine
+ * Consumes an entire offline session transcript and identifies key
+ * moments where the user could have deployed stronger structural phrasing.
+ */
+export const OutcomeReplayEngine = {
+ generateSimulation(session: Session): ReplaySimulationResult {
+ console.log(`[OutcomeReplayEngine] Analyzing Session: ${session.id}`);
+
+ const opportunities: TacticalImprovement[] = [];
+
+ // We look through all the raw patterns detected during the session
+ // Wait, the session saves patterns that crossed the >60% threshold.
+ // We want to simulate what the user SHOULD have said instead.
+ // If we want counterfactuals, we analyze the *user's* weak responses to anchors,
+ // or moments the user deployed weak objections.
+
+ session.detectedPatterns.forEach((pattern: DetectedPattern) => {
+ const opportunity = this.analyzePatternCounterfactual(pattern, session.mode);
+ if (opportunity) {
+ opportunities.push(opportunity);
+ }
+ });
+
+ const totalDelta = opportunities.reduce((acc, curr) => acc + curr.delta, 0);
+
+ const postSessionSummary: PostSessionSummary = this.generatePostSessionSummary(session);
+
+ return {
+ sessionId: session.id,
+ opportunities: opportunities.sort((a,b) => b.delta - a.delta), // highest improvement first
+ totalDeltaGained: totalDelta,
+ postSessionSummary
+ };
+ },
+
+ generatePostSessionSummary(session: Session): PostSessionSummary {
+ const patterns = session.detectedPatterns.map(p => p.pattern);
+ const hasPositive = patterns.includes(NegotiationPattern.POSITIVE_SIGNAL) || patterns.includes(NegotiationPattern.COMMITMENT_LANGUAGE);
+ const hasStrength = patterns.includes(NegotiationPattern.STRENGTH_SIGNAL) || patterns.includes(NegotiationPattern.ANCHORING);
+ const hasBudget = patterns.includes(NegotiationPattern.BUDGET_OBJECTION);
+ const hasAuthority = patterns.includes(NegotiationPattern.AUTHORITY_PRESSURE);
+
+ const summary: PostSessionSummary = {
+ whatWorked: [],
+ signalsOfInterest: [],
+ hiddenObjections: [],
+ followUpStrategy: ''
+ };
+
+ // What worked
+ if (hasStrength) summary.whatWorked.push("You actively asserted boundaries and anchored the discussion framework.");
+ if (session.duration > 180000) summary.whatWorked.push("You maintained a long-form engagement, indicating deep exploration.");
+ if (summary.whatWorked.length === 0) summary.whatWorked.push("You laid foundational groundwork for future discovery.");
+
+ // Signals of Interest
+ if (hasPositive) summary.signalsOfInterest.push("Explicit verbal agreement or enthusiasm detected.");
+ if (patterns.includes(NegotiationPattern.TIME_PRESSURE)) summary.signalsOfInterest.push("They exhibited urgency to close or rush timelines.");
+ if (summary.signalsOfInterest.length === 0) summary.signalsOfInterest.push("No overwhelming buy-in detected. They are still evaluating risk.");
+
+ // Hidden Objections
+ if (hasBudget) summary.hiddenObjections.push("Financial constraints or explicit budget ceilings.");
+ if (hasAuthority) summary.hiddenObjections.push("Decision-making power is delegated elsewhere.");
+ if (patterns.includes(NegotiationPattern.DEFLECTION)) summary.hiddenObjections.push("Unwillingness to commit to specific next steps.");
+ if (summary.hiddenObjections.length === 0) summary.hiddenObjections.push("No explicit structural traps observed.");
+
+ // Follow up Strategy
+ if (hasAuthority) {
+ summary.followUpStrategy = "Draft an executive summary specifically targeting the hidden decision-maker they mentioned.";
+ } else if (hasBudget) {
+ summary.followUpStrategy = "Send a phased implementation plan that breaks your pricing into lower-risk milestones.";
+ } else if (hasPositive) {
+ summary.followUpStrategy = "Strike while the iron is hot. Send a concrete timeline summarizing today's verbal agreement.";
+ } else {
+ summary.followUpStrategy = "Follow up with a targeted question probing their greatest risk-aversion concern.";
+ }
+
+ return summary;
+ },
+
+ analyzePatternCounterfactual(pattern: DetectedPattern, mode: NegotiationMode): TacticalImprovement | null {
+ // Base math algorithm: We penalize the original text by generating an arbitrary baseline
+ // and calculate the mathematical Persuasion Delta Score.
+ const originalStrength = Math.round(Math.random() * 20 + 30); // 30-50%
+ const improvedStrength = Math.round(Math.random() * 20 + 75); // 75-95%
+ const delta = improvedStrength - originalStrength;
+
+ switch (pattern.pattern) {
+ case NegotiationPattern.ANCHORING:
+ return {
+ originalQuote: pattern.context ?? "Transcript unavailable",
+ originalStrengthScore: originalStrength,
+ improvedReframing: `Based on your stated scope, the floor requirement sits closer to X to ensure deployment success without sacrificing reliability.`,
+ improvedStrengthScore: improvedStrength,
+ tacticType: NegotiationPattern.ANCHORING,
+ delta
+ };
+ case NegotiationPattern.BUDGET_OBJECTION:
+ return {
+ originalQuote: pattern.context ?? "Transcript unavailable",
+ originalStrengthScore: originalStrength,
+ improvedReframing: `If the current allocation is locked, we can strip away Phase 2 deliverables to meet that exact figure today.`,
+ improvedStrengthScore: improvedStrength,
+ tacticType: NegotiationPattern.BUDGET_OBJECTION,
+ delta
+ };
+ case NegotiationPattern.STRENGTH_SIGNAL:
+ return {
+ originalQuote: pattern.context ?? "Transcript unavailable",
+ originalStrengthScore: originalStrength,
+ improvedReframing: `My specific architectural decision here directly accelerated Q3 delivery by 40%, generating $X in early pipeline.`,
+ improvedStrengthScore: improvedStrength,
+ tacticType: NegotiationPattern.STRENGTH_SIGNAL,
+ delta
+ };
+ default:
+ return {
+ originalQuote: pattern.context ?? "Transcript unavailable",
+ originalStrengthScore: originalStrength,
+ improvedReframing: `Applying a structural pivot here neutralizes the frame and returns leverage to your court.`,
+ improvedStrengthScore: improvedStrength,
+ tacticType: pattern.pattern,
+ delta
+ };
+ }
+ }
+};
diff --git a/src/ai/StrategicPreparationEngine.ts b/src/ai/StrategicPreparationEngine.ts
new file mode 100644
index 0000000..81a89ed
--- /dev/null
+++ b/src/ai/StrategicPreparationEngine.ts
@@ -0,0 +1,317 @@
+/**
+ * đ PRIVACY NOTICE
+ * All strategic preparation generation runs locally on device using deterministic logic.
+ * No LLM calls. No external API calls. No data leaves this device.
+ */
+
+import { NegotiationMode, StrategicAnalysis, PreSessionInputs } from '../types/session';
+
+export interface FormField {
+ id: string;
+ label: string;
+ placeholder: string;
+ type: 'text' | 'number' | 'multiline';
+ required: boolean;
+}
+
+export class StrategicPreparationEngine {
+
+ /**
+ * Generates the required input fields based on the selected negotiation mode
+ */
+ public static getFormConfigForMode(mode: NegotiationMode): FormField[] {
+ switch (mode) {
+ case NegotiationMode.JOB_INTERVIEW:
+ return [
+ { id: 'role', label: 'Role applying for', placeholder: 'e.g. Senior Product Manager', type: 'text', required: true },
+ { id: 'company_type', label: 'Company type', placeholder: 'Startup / MNC / Agency', type: 'text', required: true },
+ { id: 'years_exp', label: 'Years of experience', placeholder: 'e.g. 5', type: 'number', required: true },
+ { id: 'top_skills', label: 'Top 3 skills', placeholder: 'e.g. Python, Leadership, System Design', type: 'text', required: true },
+ { id: 'achievement', label: 'Biggest measurable achievement', placeholder: 'e.g. Scaled revenue by 40%', type: 'multiline', required: true },
+ { id: 'expected_salary', label: 'Expected salary', placeholder: 'e.g. $150,000', type: 'text', required: true },
+ { id: 'weakness', label: 'Known weaknesses', placeholder: 'e.g. Lack of enterprise experience', type: 'multiline', required: false },
+ ];
+ case NegotiationMode.SALES:
+ return [
+ { id: 'product', label: 'Product/Service', placeholder: 'e.g. Enterprise SaaS Platform', type: 'text', required: true },
+ { id: 'target_profile', label: 'Target customer profile', placeholder: 'e.g. CTOs at mid-market companies', type: 'text', required: true },
+ { id: 'price_range', label: 'Price range', placeholder: 'e.g. $50k - $100k ARR', type: 'text', required: true },
+ { id: 'objections', label: 'Known objections', placeholder: 'e.g. Too expensive, difficult integration', type: 'multiline', required: true },
+ { id: 'competitors', label: 'Competitor names', placeholder: 'e.g. Salesforce, Oracle', type: 'text', required: false },
+ { id: 'goal', label: 'Goal of call', placeholder: 'e.g. Schedule technical demo', type: 'text', required: true },
+ ];
+ case NegotiationMode.STARTUP_PITCH:
+ return [
+ { id: 'problem', label: 'Problem statement', placeholder: 'Describe the core problem in 1 sentence', type: 'multiline', required: true },
+ { id: 'solution', label: 'Solution summary', placeholder: 'How do you fix it?', type: 'multiline', required: true },
+ { id: 'traction', label: 'Traction metrics', placeholder: 'e.g. 10k MRR, 50% MoM growth', type: 'multiline', required: true },
+ { id: 'revenue_model', label: 'Revenue model', placeholder: 'e.g. B2B Subscription', type: 'text', required: true },
+ { id: 'funding_ask', label: 'Funding ask', placeholder: 'e.g. $2M Seed', type: 'text', required: true },
+ { id: 'market_size', label: 'Target market size', placeholder: 'e.g. $10B TAM', type: 'text', required: true },
+ ];
+ case NegotiationMode.SALARY_RAISE:
+ return [
+ { id: 'current_role', label: 'Current role', placeholder: 'e.g. Marketing Director', type: 'text', required: true },
+ { id: 'current_salary', label: 'Current salary', placeholder: 'e.g. $120,000', type: 'text', required: true },
+ { id: 'market_salary', label: 'Market salary range', placeholder: 'e.g. $140,000 - $160,000', type: 'text', required: true },
+ { id: 'achievements', label: 'Key achievements', placeholder: 'What did you do this year?', type: 'multiline', required: true },
+ { id: 'manager_type', label: 'Manager personality type', placeholder: 'e.g. Analytical, Supportive, Strict', type: 'text', required: true },
+ { id: 'desired_raise', label: 'Desired raise %', placeholder: 'e.g. 15%', type: 'text', required: true },
+ ];
+ case NegotiationMode.INVESTOR_MEETING:
+ return [
+ { id: 'fund_name', label: 'Fund Name', placeholder: 'e.g. Sequoia Capital', type: 'text', required: false },
+ { id: 'fund_thesis', label: 'Fund Thesis/Focus', placeholder: 'e.g. Deeptech SaaS', type: 'text', required: false },
+ { id: 'burn_rate', label: 'Current monthly burn', placeholder: 'e.g. $50k/mo', type: 'text', required: true },
+ { id: 'runway', label: 'Months of runway left', placeholder: 'e.g. 6 months', type: 'number', required: true },
+ { id: 'valuation_cap', label: 'Target Valuation Cap', placeholder: 'e.g. $15M Post-Money', type: 'text', required: true },
+ { id: 'weakness', label: 'Biggest risk to thesis', placeholder: 'e.g. High churn rate', type: 'multiline', required: true },
+ ];
+ case NegotiationMode.CLIENT_NEGOTIATION:
+ return [
+ { id: 'client_name', label: 'Client / Company Name', placeholder: 'e.g. Acme Corp', type: 'text', required: true },
+ { id: 'project_scope', label: 'Project Scope', placeholder: 'Briefly describe the deliverables', type: 'multiline', required: true },
+ { id: 'timeline', label: 'Proposed Timeline', placeholder: 'e.g. 3 Months', type: 'text', required: true },
+ { id: 'budget', label: 'Client Budget (if known)', placeholder: 'e.g. $25k', type: 'text', required: false },
+ { id: 'leverage', label: 'Your Leverage', placeholder: 'Why do they need YOU specifically?', type: 'multiline', required: true },
+ ];
+ case NegotiationMode.CUSTOM_SCENARIO:
+ default:
+ return [
+ { id: 'scenario_desc', label: 'Describe the scenario', placeholder: 'Who are you talking to and what do you want?', type: 'multiline', required: true },
+ { id: 'your_goal', label: 'Your ultimate goal', placeholder: 'What is a "win" for you?', type: 'text', required: true },
+ { id: 'their_goal', label: 'Their likely goal', placeholder: 'What do they want out of this?', type: 'text', required: true },
+ { id: 'leverage', label: 'Your Leverage', placeholder: 'What advantages do you hold?', type: 'multiline', required: true },
+ { id: 'risks', label: 'Biggest Risk / Weakness', placeholder: 'What are you afraid they will bring up?', type: 'multiline', required: false },
+ ];
+ }
+ }
+
+ /**
+ * Deterministically generates the 10-point Strategic Analysis plan completely offline
+ */
+ public static generateStrategicAnalysis(mode: NegotiationMode, inputs: PreSessionInputs): StrategicAnalysis {
+ console.log(`[StrategicPreparationEngine] Generating analysis for ${mode}...`);
+
+ // Base template
+ const analysis: StrategicAnalysis = {
+ powerPositioning: '',
+ likelyObjections: [],
+ psychologicalTactics: [],
+ recommendedResponses: [],
+ highImpactPhrases: [],
+ phrasesToAvoid: [],
+ confidenceTriggers: [],
+ openingScript: '',
+ closingScript: '',
+ mistakesToAvoid: [],
+ };
+
+ switch (mode) {
+ case NegotiationMode.JOB_INTERVIEW:
+ analysis.powerPositioning = `Position yourself not as an applicant, but as a peer evaluating a mutual fit. Your anchor is your achievement: ${inputs.achievement || 'your strong track record'}. Leverage your ${inputs.years_exp || '*'} years of experience as proof of execution risk reduction for the ${inputs.company_type || 'company'}.`;
+
+ analysis.likelyObjections = [
+ `"We are looking for someone with more experience in [Specific Niche]."`,
+ `"Your salary expectation of ${inputs.expected_salary || 'this range'} is above our current band."`,
+ inputs.weakness ? `"Can you explain your background regarding ${inputs.weakness}?"` : `"Are you comfortable operating outside your core skillset?"`
+ ];
+
+ analysis.psychologicalTactics = [
+ 'The Pause: They may stay silent after you answer to force you to over-explain. Do not fill the silence.',
+ 'The Low Anchor: Suggesting a title or compensation lower than market to test your boundary.',
+ 'Pressure Testing: Deliberately challenging your achievements to see if you get defensive.'
+ ];
+
+ analysis.recommendedResponses = [
+ `If they challenge your weakness: "That is an area I'm actively bridging, but my core strength in ${inputs.top_skills?.split(',')[0] || 'execution'} allows me to drive results while I adapt quickly."`,
+ `If they press on salary early: "I'd prefer to ensure I'm the perfect fit for the ${inputs.role || 'role'} before we lock in compensation. Does that work for you?"`,
+ `If they offer a low anchor: "Based on the market rate for ${inputs.years_exp || 'my'} years of experience and the scope of this role, my floor is higher than that."`
+ ];
+
+ analysis.highImpactPhrases = [
+ '"I drove...", "I implemented...", "The measurable outcome was..."',
+ '"My thesis for this role is..."',
+ '"How does this role directly impact top-line revenue?"'
+ ];
+
+ analysis.phrasesToAvoid = [
+ '"I think...", "I believe...", "I feel like..."',
+ '"I was part of a team that..." (Use "I led" or "I owned")',
+ '"I really need this job."'
+ ];
+
+ analysis.confidenceTriggers = [
+ 'Maintain 3 seconds of eye contact when stating your achievement.',
+ 'Keep your hands visible on the table (if in person or deep frame video).',
+ 'Drop your vocal pitch slightly at the end of sentences to convey authority.'
+ ];
+
+ analysis.openingScript = `"It's great to connect. I've been following [Company]'s recent moves, and I'm excited to explore how my background in ${inputs.top_skills?.split(',')[0] || 'my field'} can help accelerate your goals for this quarter."`;
+
+ analysis.closingScript = `"Based on our conversation, I'm confident I can execute on [Their Core Metric]. What timelines should I expect for next steps so I can align my other conversations?"`;
+
+ analysis.mistakesToAvoid = [
+ 'Revealing your current salary (it caps your future salary).',
+ 'Rambling for more than 90 seconds on a single answer.',
+ 'Asking logistical questions (hours, vacation) before an offer is extended.'
+ ];
+ break;
+
+ case NegotiationMode.SALARY_RAISE:
+ analysis.powerPositioning = `You are presenting a business case, not a personal plea. You are a highly-performing asset (${inputs.current_role || 'in your role'}) seeking market alignment. Your request of ${inputs.desired_raise || 'a raise'} is justified by the ROI you provided: ${inputs.achievements || 'your recent contributions'}.`;
+
+ analysis.likelyObjections = [
+ `"The budget is frozen until next quarter."`,
+ `"You are already at the top of the band for your title."`,
+ `"We need to see more leadership before we can authorize a ${inputs.desired_raise || 'raise'} jump."`
+ ];
+
+ analysis.psychologicalTactics = [
+ 'Sympathy Play: "I really wish I could, but my hands are tied by HR/Finance." (Authority Pressure)',
+ 'The Delay: "Let\'s circle back to this during annual reviews in 6 months." (Deflection)',
+ 'The Guilt Trip: "Times are tough for the team right now." (Negative Signal)'
+ ];
+
+ analysis.recommendedResponses = [
+ `If budget is frozen: "If base compensation is locked, are we able to explore an off-cycle bonus structure or equity grant tied to my recent ${inputs.achievements?.substring(0,20) || 'successes'}?"`,
+ `If deferred to later: "To ensure we have a productive conversation then, can we put in writing the exact metrics required to unlock the ${inputs.market_salary || 'market'} range?"`,
+ `If given a hard no: "I appreciate the transparency. For me to continue operating at peak capacity, I need a clear pathway to market-rate compensation. How can we build that?"`
+ ];
+
+ analysis.highImpactPhrases = [
+ '"Market alignment," "Value capture," "Data shows..."',
+ '"Based on the scope of my current contributions..."',
+ '"My priority is continuing to drive value here."'
+ ];
+
+ analysis.phrasesToAvoid = [
+ '"I need the money for [personal reason]."',
+ '"I haven\'t had a raise in X years." (Focus on value, not time)',
+ '"If I don\'t get this, I will quit." (Never issue ultimatums unless you have an offer in hand)'
+ ];
+
+ analysis.confidenceTriggers = [
+ 'Bring physical data: Have a printed sheet or shared screen showing the market data and your achievements.',
+ 'Silence after the ask. State your number, then stop talking.',
+ 'Maintain a collaborative, non-combative posture.'
+ ];
+
+ analysis.openingScript = `"Thank you for taking the time to meet. The purpose of this sync is to review my recent contributionsâspecifically ${inputs.achievements?.substring(0,30) || 'my recent wins'}âand discuss aligning my compensation with both my current output and the market rate."`;
+
+ analysis.closingScript = `"I appreciate your time going over this. Iâll send a summary email outlining the milestones we discussed to reach the ${inputs.market_salary || 'target'} band by [Date]."`;
+
+ analysis.mistakesToAvoid = [
+ 'Apologizing for asking for money.',
+ 'Getting visibly emotional or defensive if rejected.',
+ 'Negotiating against yourself before they even respond.'
+ ];
+ break;
+
+ case NegotiationMode.SALES:
+ analysis.powerPositioning = `You are a high-value consultant diagnosing a painful problem, not a vendor pushing a product. Your ${inputs.product || 'solution'} is the antidote to the friction plaguing their ${inputs.target_profile || 'team'}. Control the frame by asking diagnostic questions.`;
+
+ analysis.likelyObjections = [
+ inputs.objections || `"Your solution is too expensive for our current budget."`,
+ inputs.competitors ? `"We are already looking at ${inputs.competitors.split(',')[0]} and they are cheaper."` : `"We are satisfied with our current manual process."`,
+ `"This isn't a priority for this quarter."`
+ ];
+
+ analysis.psychologicalTactics = [
+ 'The Silent Treatment: Letting you pitch into the void to drain your confidence and make you offer discounts.',
+ 'Phantom Authority: Pretending they have decision power to extract information, then claiming they need to "run it by the boss." (Authority Pressure)',
+ 'Commoditization: Comparing your complex solution to a basic feature to drive down the price.'
+ ];
+
+ analysis.recommendedResponses = [
+ `If they claim it's too expensive: "Expensive compared to what? What is the current cost of doing nothing for another 6 months?"`,
+ `If they mention competitors: "We respect ${inputs.competitors?.split(',')[0] || 'them'}. They are great for basic needs. Our clients choose us when they need [Your Unique Value Proposition]."`,
+ `If urgency is low: "I hear you. If we pause this until next quarter, how will you handle the fallout from [Specific Pain Point] in the meantime?"`
+ ];
+
+ analysis.highImpactPhrases = [
+ '"What happens if you do nothing?"',
+ `"Typically, ${inputs.target_profile || 'leaders'} in your position tell me..."`,
+ '"Is it fair to say..." (Calibrated question)'
+ ];
+
+ analysis.phrasesToAvoid = [
+ '"Just checking in / following up..." (Provides zero value)',
+ '"To be honest with you..." (Implies you weren\'t honest before)',
+ '"Does that make sense?" (Can sound patronizing)'
+ ];
+
+ analysis.confidenceTriggers = [
+ 'Mirroring: Repeat the last 1-3 words of their sentence to encourage them to elaborate without you asking a question.',
+ 'Slow your cadence down by 20%.',
+ 'Use downward inflection at the end of statements to project certainty.'
+ ];
+
+ analysis.openingScript = `"I appreciate you carving out time. My goal today isn't to pitch you ${inputs.product || 'our product'}âit's to determine if we are a fit. Are you open to me asking a few targeted questions about how you're currently handling [Problem Area]?"`;
+
+ analysis.closingScript = `"Based on what you've shared about [Their Pain Point], I believe there is a strong fit. To ensure we respect your time, are you the sole decision-maker for the ${inputs.price_range || 'allocated'} budget, or should we include anyone else on the next ${inputs.goal || 'demo'}?"`;
+
+ analysis.mistakesToAvoid = [
+ 'Pitching features instead of diagnosing pain.',
+ 'Answering unasked objections out of nervousness.',
+ 'Ending the call without a concrete commitment for the next step on the calendar.'
+ ];
+ break;
+
+ // ... Add similar logic branches for Startup Pitch, Investor Meeting, Client, and Custom
+ // Default to Custom logic if unhandled
+ default:
+ analysis.powerPositioning = `Root your frame in absolute certainty. You have leverage because ${inputs.leverage || 'you bring unique value'}. Do not enter the conversion from a defensive posture; you are exploring a mutual exchange of value.`;
+
+ analysis.likelyObjections = [
+ inputs.risks || `"I don't think we can accommodate that request."`,
+ `"This requires further review from other stakeholders."`,
+ `"We need to see a timeline shift to make this viable."`
+ ];
+
+ analysis.psychologicalTactics = [
+ 'Anchoring: Opening with an extreme position to drag the midpoint in their favor.',
+ 'Time Restraints: "I only have 5 minutes." (Rushing you into poor decisions).',
+ 'Flinching: Visibly acting shocked at your proposal to induce guilt.'
+ ];
+
+ analysis.recommendedResponses = [
+ `If they flinch: Remain completely silent. Do not justify your position until they articulate a specific argument.`,
+ `If they claim lack of authority: "Who else needs to be involved, and can we get them on the line now?"`,
+ `To protect your goal of ${inputs.your_goal || 'success'}: "If we cannot agree on this, what is your proposed alternative that still solves my core requirement?"`
+ ];
+
+ analysis.highImpactPhrases = [
+ '"It sounds like [Their goal] is a priority for you..." (Labeling)',
+ '"How am I supposed to do that?" (The ultimate deferral question)',
+ '"Let\'s put a pin in that and address [Your Priority] first."'
+ ];
+
+ analysis.phrasesToAvoid = [
+ '"I\'m sorry but...", "I hope...", "I\'ll try..."',
+ '"Is that okay with you?" (Weak framing)',
+ 'Any nervous laughter.'
+ ];
+
+ analysis.confidenceTriggers = [
+ 'Use Late-Night FM DJ Voice: Deep, slow, calming inflection.',
+ 'Take up physical space. Do not cross arms or shrink posture.',
+ 'Embrace the awkward silence after you make a demand.'
+ ];
+
+ analysis.openingScript = `"I'm glad we could connect. My objective today is to find a pathway to ${inputs.your_goal || 'our mutual goal'}, while addressing your need for ${inputs.their_goal || 'efficiency'}. Are you open to exploring the variables on the table?"`;
+
+ analysis.closingScript = `"We've covered significant ground. To formalize this, I will draft a summary emphasizing how we leverage ${inputs.leverage?.substring(0, 20) || 'our mutual assets'}. Let's target [Date] of next week for finalizing signatures."`;
+
+ analysis.mistakesToAvoid = [
+ 'Speaking first after laying a major proposal on the table.',
+ 'Conceding a variable without demanding something in return.',
+ 'Letting the opponent dictate the agenda.'
+ ];
+ break;
+ }
+
+ console.log('[StrategicPreparationEngine] Analysis generated successfully.');
+ return analysis;
+ }
+}
diff --git a/src/ai/WhisperAutoCorrector.ts b/src/ai/WhisperAutoCorrector.ts
new file mode 100644
index 0000000..c858187
--- /dev/null
+++ b/src/ai/WhisperAutoCorrector.ts
@@ -0,0 +1,124 @@
+import { NegotiationMode } from '../types/session';
+import { PATTERN_LIBRARY, MODE_INTENT_MATRIX } from './patternLibrary';
+
+/**
+ * Calculates the Levenshtein distance between two strings.
+ */
+export function levenshteinDistance(s1: string, s2: string): number {
+ if (s1.length === 0) return s2.length;
+ if (s2.length === 0) return s1.length;
+
+ const m = s1.length;
+ const n = s2.length;
+ const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
+
+ for (let i = 0; i <= m; i++) dp[i][0] = i;
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
+
+ for (let i = 1; i <= m; i++) {
+ for (let j = 1; j <= n; j++) {
+ const cost = s1[i - 1].toLowerCase() === s2[j - 1].toLowerCase() ? 0 : 1;
+ dp[i][j] = Math.min(
+ dp[i - 1][j] + 1, // deletion
+ dp[i][j - 1] + 1, // insertion
+ dp[i - 1][j - 1] + cost // substitution
+ );
+ }
+ }
+ return dp[m][n];
+}
+
+/**
+ * Extracts a flattened, unique list of vocabulary words for a given negotiation mode.
+ * This includes both topic tags and simple structural pattern words.
+ */
+function getModeVocabulary(mode: NegotiationMode): string[] {
+ const modeMatrix = MODE_INTENT_MATRIX[mode] || {};
+ const intentKeys = Object.keys(modeMatrix) as Array;
+
+ const vocabSet = new Set();
+
+ intentKeys.forEach((intent) => {
+ // Only process intents that are actually weighted for this mode > 0
+ if ((modeMatrix as any)[intent] > 0) {
+ const tactic = PATTERN_LIBRARY[intent];
+
+ // Add topic tags
+ if (tactic.topicTags) {
+ tactic.topicTags.forEach((tag: string) => vocabSet.add(tag.toLowerCase()));
+ }
+
+ // Attempt to extract raw words from simple structural regexes
+ if (tactic.structuralPatterns) {
+ tactic.structuralPatterns.forEach((pattern: string) => {
+ // Remove regex artifacts like .* or ^ or \b
+ const words = pattern.replace(/[.*^$\\?|()[\]{}]/g, ' ')
+ .split(/\s+/)
+ .filter((w: string) => w.length > 3); // Only preserve meaningful words > 3 chars
+ words.forEach((w: string) => vocabSet.add(w.toLowerCase()));
+ });
+ }
+ }
+ });
+
+ return Array.from(vocabSet);
+}
+
+// Cache vocabulary per mode to prevent recalculating on every transcript chunk
+const vocabCache: Record = {};
+
+function getCachedVocabulary(mode: NegotiationMode): string[] {
+ if (!vocabCache[mode]) {
+ vocabCache[mode] = getModeVocabulary(mode);
+ }
+ return vocabCache[mode];
+}
+
+/**
+ * Auto-corrects a Whisper transcription chunk by fuzzy matching misheard words
+ * against the expected negotiation mode vocabulary.
+ */
+export function autoCorrectTranscript(transcript: string, mode: NegotiationMode): string {
+ if (!transcript) return transcript;
+
+ const vocab = getCachedVocabulary(mode);
+ if (vocab.length === 0) return transcript;
+
+ // Split into words, preserving punctuation if possible by matching words
+ // e.g. "Wait, what?" -> ["Wait", ",", " ", "what", "?"]
+ // We'll just tokenize by word boundaries and check the alphabetic chunks
+ const tokens = transcript.split(/(\b[a-zA-Z]+\b)/);
+
+ const correctedTokens = tokens.map(token => {
+ // Only fuzz alpha words that are reasonably long
+ if (/^[a-zA-Z]+$/.test(token) && token.length >= 4) {
+ let bestMatch = token;
+ let minDistance = Infinity;
+
+ for (const vWord of vocab) {
+ // Skip comparing if lengths are vastly different
+ if (Math.abs(vWord.length - token.length) > 2) continue;
+
+ const dist = levenshteinDistance(token, vWord);
+
+ // Threshold logic:
+ // Length 4-5: allow 1 error
+ // Length 6+: allow 2 errors
+ const maxErrors = token.length >= 6 ? 2 : 1;
+
+ if (dist <= maxErrors && dist < minDistance) {
+ minDistance = dist;
+ // Match the original casing if possible. Simple heuristic: match first letter casing
+ const isCapitalized = token[0] === token[0].toUpperCase();
+ bestMatch = isCapitalized
+ ? vWord.charAt(0).toUpperCase() + vWord.slice(1)
+ : vWord;
+ }
+ }
+ return bestMatch;
+ }
+ return token;
+ });
+
+ return correctedTokens.join('');
+}
diff --git a/src/ai/intentClassifier.ts b/src/ai/intentClassifier.ts
new file mode 100644
index 0000000..66103bc
--- /dev/null
+++ b/src/ai/intentClassifier.ts
@@ -0,0 +1,212 @@
+/**
+ * đ PRIVACY NOTICE
+ * All pattern classification runs locally on device using rule-based NLP.
+ * No external API calls. No data leaves this device.
+ */
+
+import {
+ NegotiationPattern,
+ NegotiationMode,
+ DetectedPattern,
+ PatternSeverity,
+} from '../types/session';
+import { PATTERN_LIBRARY, PatternDefinition, getModeConfig, MODE_INTENT_MATRIX } from './patternLibrary';
+
+/**
+ * Classify text and detect negotiation patterns using Structural Intelligence Rules
+ */
+export const classifyIntent = (
+ text: string,
+ mode: NegotiationMode,
+ sensitivityMultiplier: number = 1.0
+): DetectedPattern[] => {
+ console.log('[IntentClassifier] đ classifyIntent() called');
+ console.log('[IntentClassifier] đ Text:', text);
+ console.log('[IntentClassifier] đ¯ Mode:', mode);
+
+ const lowerText = text.toLowerCase();
+ const timestamp = Date.now();
+ const candidates: { patternDef: PatternDefinition; score: number }[] = [];
+
+ // Check if text contains numbers
+ const hasNumbers = /\d+/.test(lowerText) || /\b(one|two|three|four|five|six|seven|eight|nine|ten|hundred|thousand|million|k|m)\b/.test(lowerText);
+
+ // 1. Evaluate every pattern
+ for (const pattern of Object.values(NegotiationPattern)) {
+ const patternDef = PATTERN_LIBRARY[pattern];
+ if (!patternDef) continue;
+
+ // Structural Match Score
+ let structuralScore = 0;
+ for (const structPattern of patternDef.structuralPatterns) {
+ const regex = new RegExp(structPattern, 'i');
+ if (regex.test(lowerText)) {
+ structuralScore = 1.0;
+ break; // Only need one structural match
+ }
+ }
+
+ // Topic Match Score
+ let topicScore = 0;
+ for (const tag of patternDef.topicTags) {
+ const regex = new RegExp(`\\b${tag}\\b`, 'i');
+ if (regex.test(lowerText)) {
+ topicScore += 0.5; // Need at least 2 distinct topic words to max out, or 1 is 50%
+ }
+ }
+ topicScore = Math.min(topicScore, 1.0);
+
+ if (structuralScore === 0 && topicScore === 0) continue; // Skip if completely irrelevant
+
+ // Numeric Boost Score
+ let numericScore = 0;
+ if (patternDef.requiresNumber) {
+ if (hasNumbers) {
+ numericScore = 1.0;
+ } else {
+ // Punish severely if required number is missing
+ continue;
+ }
+ }
+
+ // Negative Signal Penalty
+ let negativePenalty = 0;
+ for (const neg of patternDef.negativeSignals) {
+ if (lowerText.includes(neg.toLowerCase())) {
+ negativePenalty = 1.0;
+ break;
+ }
+ }
+
+ // Dynamic Base Weighting:
+ // If structure matches exactly, they get full baseWeight (e.g. 0.70)
+ // If only isolated topic words match, they get partial base weight (e.g. 0.49)
+ let primaryMatchScore = structuralScore > 0 ? patternDef.baseWeight : (patternDef.baseWeight * 0.7);
+
+ // Combine Base Scores
+ let rawScore = primaryMatchScore
+ + (0.25 * topicScore)
+ + (0.10 * numericScore)
+ - (0.25 * negativePenalty);
+
+ // Apply Mode Intelligence Filter Matrix
+ let modeAdjustment = 0;
+ const modeMatrix = MODE_INTENT_MATRIX[mode];
+ if (modeMatrix) {
+ if (modeMatrix.highWeight.includes(patternDef.intent)) {
+ modeAdjustment += 0.15;
+ } else if (modeMatrix.lowWeight.includes(patternDef.intent)) {
+ modeAdjustment -= 0.15;
+ }
+ }
+
+ const finalScore = (rawScore + modeAdjustment) * sensitivityMultiplier;
+
+ // Convert to percentage
+ const finalConfidenceScore = Math.min(Math.max(finalScore * 100, 0), 100);
+
+ candidates.push({ patternDef, score: finalConfidenceScore });
+
+ // Format logs for Demo display
+ console.log(`[IntentClassifier] ⥠Evaluated: ${patternDef.intent}`);
+ console.log(`[IntentClassifier] ââ Structural Score: ${structuralScore}`);
+ console.log(`[IntentClassifier] ââ Topic Score: ${topicScore}`);
+ console.log(`[IntentClassifier] ââ Numeric Score: ${numericScore} (Required: ${patternDef.requiresNumber})`);
+ console.log(`[IntentClassifier] ââ Negative Penalty: ${negativePenalty}`);
+ console.log(`[IntentClassifier] ââ Mode Adjustment: ${modeAdjustment > 0 ? '+' : ''}${Math.round(modeAdjustment * 100)}% (${mode})`);
+ console.log(`[IntentClassifier] ââ FINAL CONFIDENCE: ${Math.round(finalConfidenceScore)}%`);
+ }
+
+ // 2. Highest Confidence Wins. Only push the Absolute Winner > 60%
+ if (candidates.length === 0) return [];
+
+ candidates.sort((a, b) => b.score - a.score);
+ const winner = candidates[0];
+
+ console.log('[IntentClassifier] đ Winning Tactic:', winner.patternDef.intent, 'at', Math.round(winner.score) + '%');
+
+ if (winner.score >= 60) {
+ const suggestion = pickRandomSuggestion(winner.patternDef.suggestions);
+
+ return [{
+ id: `${winner.patternDef.intent}_${timestamp}`,
+ pattern: winner.patternDef.intent,
+ confidenceScore: winner.score,
+ suggestion,
+ severity: winner.patternDef.severity,
+ timestamp,
+ transcript: text,
+ context: text.substring(0, 50) + "...",
+ }];
+ }
+
+ return [];
+};
+
+
+/**
+ * Pick a random suggestion from array
+ */
+const pickRandomSuggestion = (suggestions: string[]): string => {
+ if (!suggestions || suggestions.length === 0) return "Acknowledge and pivot.";
+ return suggestions[Math.floor(Math.random() * suggestions.length)];
+};
+
+/**
+ * Filter patterns by severity
+ */
+export const filterBySeverity = (
+ patterns: DetectedPattern[],
+ minSeverity: PatternSeverity
+): DetectedPattern[] => {
+ const severityOrder: Record = {
+ low: 1,
+ medium: 2,
+ high: 3,
+ };
+
+ const minLevel = severityOrder[minSeverity];
+
+ return patterns.filter((p) => severityOrder[p.severity] >= minLevel);
+};
+
+/**
+ * Get most common pattern from array
+ */
+export const getMostCommonPattern = (patterns: DetectedPattern[]): NegotiationPattern | null => {
+ if (patterns.length === 0) return null;
+
+ const counts: Record = {};
+
+ patterns.forEach((p) => {
+ counts[p.pattern] = (counts[p.pattern] || 0) + 1;
+ });
+
+ let maxCount = 0;
+ let mostCommon: NegotiationPattern | null = null;
+
+ Object.entries(counts).forEach(([pattern, count]) => {
+ if (count > maxCount) {
+ maxCount = count;
+ mostCommon = pattern as NegotiationPattern;
+ }
+ });
+
+ return mostCommon;
+};
+
+// Legacy stubs missing from PatternLibrary refactor requirement
+export const containsPattern = () => false;
+export const getSuggestionForPattern = () => "";
+export const analyzeTranscriptWindow = (
+ chunks: Array<{ text: string; timestamp: number }>,
+ mode: NegotiationMode,
+ sensitivityMultiplier: number = 1.0,
+ windowSize: number = 2 // Update to Last 2 chunks as requested
+): DetectedPattern[] => {
+ // Take last 2 chunks
+ const recentChunks = chunks.slice(-windowSize);
+ const combinedText = recentChunks.map((c) => c.text).join(' ');
+
+ return classifyIntent(combinedText, mode, sensitivityMultiplier);
+};
diff --git a/src/ai/patternLibrary.ts b/src/ai/patternLibrary.ts
new file mode 100644
index 0000000..fa71319
--- /dev/null
+++ b/src/ai/patternLibrary.ts
@@ -0,0 +1,389 @@
+/**
+ * đ PRIVACY NOTICE
+ * All pattern detection runs locally on device.
+ * No data leaves this device.
+ */
+
+import { NegotiationPattern, NegotiationMode, ModeConfig } from '../types/session';
+
+/**
+ * Pattern definition with structural detection rules
+ */
+export interface PatternDefinition {
+ intent: NegotiationPattern;
+ displayName: string;
+ description: string;
+ structuralPatterns: string[]; // Regex strings
+ topicTags: string[]; // Context keywords
+ requiresNumber: boolean; // Does it require numeric value like $50k or 60?
+ negativeSignals: string[]; // Penalize score if present (e.g. "maybe")
+ baseWeight: number; // Base confidence 0.0 - 1.0
+ severity: 'low' | 'medium' | 'high';
+ suggestions: string[];
+}
+
+/**
+ * Complete pattern library for negotiation detection using Structural Logic
+ */
+export const PATTERN_LIBRARY: Record = {
+ [NegotiationPattern.ANCHORING]: {
+ intent: NegotiationPattern.ANCHORING,
+ displayName: 'Anchoring',
+ description: 'Setting initial price/expectation reference point',
+ structuralPatterns: [
+ 'we .* offer',
+ 'the .* range',
+ 'typically .* is',
+ 'industry standard',
+ 'expecting .* around',
+ 'base .* is',
+ 'looking .* for',
+ 'budget .* is',
+ ],
+ topicTags: ['salary', 'compensation', 'package', 'budget', 'price', 'cost', 'pay', 'base', 'range'],
+ requiresNumber: true,
+ negativeSignals: ['maybe', 'if possible', 'eventually'],
+ baseWeight: 0.65,
+ severity: 'high',
+ suggestions: [
+ 'Ask for a detailed cost breakdown',
+ 'Present your own alternative anchor point',
+ 'Request comparison data from multiple sources',
+ ],
+ },
+
+ [NegotiationPattern.BUDGET_OBJECTION]: {
+ intent: NegotiationPattern.BUDGET_OBJECTION,
+ displayName: 'Budget Objection',
+ description: 'Claiming budget constraints or financial limitations',
+ structuralPatterns: [
+ 'too expensive',
+ 'outside .* budget',
+ 'cost.* high',
+ 'we cannot afford',
+ 'beyond .* means',
+ 'budget is',
+ 'pricy',
+ 'not in .* budget',
+ "don't have .* budget",
+ 'too much',
+ 'pricey',
+ ],
+ topicTags: ['money', 'budget', 'cost', 'expensive', 'price', 'afford', 'funds', 'capital'],
+ requiresNumber: false,
+ negativeSignals: ['flexible', 'might work', 'open to'],
+ baseWeight: 0.70,
+ severity: 'high',
+ suggestions: [
+ 'Ask about budget flexibility and approval thresholds',
+ 'Break pricing into smaller phases or milestones',
+ 'Highlight ROI and value instead of focusing on cost',
+ ],
+ },
+
+ [NegotiationPattern.AUTHORITY_PRESSURE]: {
+ intent: NegotiationPattern.AUTHORITY_PRESSURE,
+ displayName: 'Authority Pressure',
+ description: 'Deferring to higher authority or claiming lack of decision power',
+ structuralPatterns: [
+ 'as per policy',
+ 'management decided',
+ 'company standard',
+ 'HR guideline',
+ 'need .* approval',
+ 'check with .* boss',
+ 'run it by',
+ 'up to my manager',
+ "don't have .* authority",
+ 'out of my hands',
+ ],
+ topicTags: ['manager', 'boss', 'approval', 'policy', 'guideline', 'hr', 'director', 'vp', 'board'],
+ requiresNumber: false,
+ negativeSignals: ['i decide', 'my call', 'we can do'],
+ baseWeight: 0.75,
+ severity: 'medium',
+ suggestions: [
+ 'Ask for the specific decision criteria being used',
+ 'Suggest a joint review with all stakeholders',
+ 'Delay final commitment until decision-maker is present',
+ ],
+ },
+
+ [NegotiationPattern.TIME_PRESSURE]: {
+ intent: NegotiationPattern.TIME_PRESSURE,
+ displayName: 'Time Pressure',
+ description: 'Creating urgency or imposing deadlines',
+ structuralPatterns: [
+ 'we need this today',
+ 'deadline is',
+ 'urgent decision',
+ 'limited time',
+ 'offer expires',
+ 'need this by',
+ 'as soon as possible',
+ 'right away',
+ 'move fast',
+ 'quickly',
+ 'by the end of',
+ ],
+ topicTags: ['today', 'tomorrow', 'urgent', 'deadline', 'asap', 'quickly', 'rush', 'speed', 'now'],
+ requiresNumber: false,
+ negativeSignals: ['no rush', 'take your time', 'whenever'],
+ baseWeight: 0.65,
+ severity: 'medium',
+ suggestions: [
+ 'Ask if the deadline is truly final or flexible',
+ 'Introduce a new variable to reset the timeline',
+ 'Propose a pause â revisit with fresh perspective',
+ ],
+ },
+
+ [NegotiationPattern.DEFLECTION]: {
+ intent: NegotiationPattern.DEFLECTION,
+ displayName: 'Deflection',
+ description: 'Avoiding commitment or postponing decision via topic shift',
+ structuralPatterns: [
+ "let's focus on",
+ "not the main issue",
+ "get back to that",
+ "right now .* discussing",
+ "circle back",
+ "talk about .* later",
+ "park that",
+ "take that offline",
+ "moving on",
+ ],
+ topicTags: ['later', 'another time', 'focus', 'discussing', 'issue', 'offline', 'park'],
+ requiresNumber: false,
+ negativeSignals: ['i agree', 'let us decide now', 'perfect'],
+ baseWeight: 0.60,
+ severity: 'medium',
+ suggestions: [
+ 'Pin down specific concerns driving the hesitation',
+ 'Set a concrete follow-up date and time right now',
+ 'Ask what information would help them decide today',
+ ],
+ },
+
+ [NegotiationPattern.STRENGTH_SIGNAL]: {
+ intent: NegotiationPattern.STRENGTH_SIGNAL,
+ displayName: 'Strength Signal',
+ description: 'Showcasing positive applicant background or achievements',
+ structuralPatterns: [
+ 'led a team',
+ 'achieved',
+ 'improved .* by',
+ 'increased revenue',
+ 'managed .* projects',
+ 'successfully delivered',
+ 'spearheaded',
+ 'was responsible for',
+ 'top performer',
+ 'exceeded goals',
+ ],
+ topicTags: ['leadership', 'revenue', 'successful', 'achieved', 'driven', 'managed', 'delivered', 'exceeded', 'impact'],
+ requiresNumber: false,
+ negativeSignals: ['assisted', 'helped', 'was part of'],
+ baseWeight: 0.70,
+ severity: 'low',
+ suggestions: [
+ 'Leverage this momentum to position yourself firmly',
+ 'Directly tie this achievement to their current needs',
+ 'Use this high-value moment to pivot to compensation framing',
+ ],
+ },
+
+ [NegotiationPattern.POSITIVE_SIGNAL]: {
+ intent: NegotiationPattern.POSITIVE_SIGNAL,
+ displayName: 'Positive Signal',
+ description: 'Expressions of interest, agreement, or enthusiasm',
+ structuralPatterns: [
+ 'sounds good',
+ 'makes sense',
+ 'that works',
+ 'i like that',
+ 'great idea',
+ 'we agree',
+ 'we can do that',
+ 'looks perfect',
+ 'exactly right',
+ ],
+ topicTags: ['yes', 'agree', 'perfect', 'exactly', 'love', 'great', 'awesome', 'good'],
+ requiresNumber: false,
+ negativeSignals: ['however', 'but'],
+ baseWeight: 0.80,
+ severity: 'low',
+ suggestions: [
+ 'Capitalize on momentum â move toward commitment',
+ 'Summarize agreed points and lock them in writing',
+ 'Ask about next steps while enthusiasm is high',
+ ],
+ },
+
+ [NegotiationPattern.NEGATIVE_SIGNAL]: {
+ intent: NegotiationPattern.NEGATIVE_SIGNAL,
+ displayName: 'Negative Signal',
+ description: 'Expressions of concern, disagreement, or rejection',
+ structuralPatterns: [
+ 'not sure',
+ 'worried about',
+ "don't think",
+ 'is a problem',
+ 'have concerns',
+ 'cannot work',
+ 'does not work',
+ 'impossible',
+ 'dealbreaker',
+ ],
+ topicTags: ['issue', 'problem', 'concerned', 'skeptical', 'worry', 'no', 'cannot', 'dealbreaker'],
+ requiresNumber: false,
+ negativeSignals: ['maybe', 'could work'],
+ baseWeight: 0.75,
+ severity: 'high',
+ suggestions: [
+ 'Probe for the specific concern behind the negativity',
+ 'Acknowledge their worry and provide concrete evidence',
+ 'Offer an alternative approach that addresses the issue',
+ ],
+ },
+
+ [NegotiationPattern.COMMITMENT_LANGUAGE]: {
+ intent: NegotiationPattern.COMMITMENT_LANGUAGE,
+ displayName: 'Commitment Language',
+ description: 'Strong commitment or decision-making language',
+ structuralPatterns: [
+ "let's do it",
+ 'we are in',
+ 'ready to start',
+ 'sign the',
+ 'move forward',
+ 'go ahead',
+ "let's proceed",
+ ],
+ topicTags: ['deal', 'agreed', 'commit', 'proceed', 'sign', 'forward', 'start'],
+ requiresNumber: false,
+ negativeSignals: ['maybe later', 'soon'],
+ baseWeight: 0.85,
+ severity: 'low',
+ suggestions: [
+ 'Document the agreement immediately in writing',
+ 'Clarify all remaining terms before finalizing',
+ 'Confirm timeline and deliverables for next steps',
+ ],
+ }
+};
+
+/**
+ * Filter Weights Arrays
+ */
+export interface ModeMatrix {
+ highWeight: NegotiationPattern[];
+ mediumWeight: NegotiationPattern[];
+ lowWeight: NegotiationPattern[];
+}
+
+/**
+ * Global Mode Intelligence Filter Matrix
+ * Controls penalty / boost multipliers internally for IntentClassifier Context Check
+ */
+export const MODE_INTENT_MATRIX: Record = {
+ [NegotiationMode.SALARY_RAISE]: {
+ highWeight: [NegotiationPattern.ANCHORING, NegotiationPattern.BUDGET_OBJECTION],
+ mediumWeight: [NegotiationPattern.AUTHORITY_PRESSURE, NegotiationPattern.COMMITMENT_LANGUAGE],
+ lowWeight: [NegotiationPattern.STRENGTH_SIGNAL, NegotiationPattern.DEFLECTION],
+ },
+ [NegotiationMode.JOB_INTERVIEW]: {
+ highWeight: [NegotiationPattern.STRENGTH_SIGNAL, NegotiationPattern.DEFLECTION],
+ mediumWeight: [NegotiationPattern.POSITIVE_SIGNAL, NegotiationPattern.TIME_PRESSURE],
+ lowWeight: [NegotiationPattern.ANCHORING, NegotiationPattern.BUDGET_OBJECTION],
+ },
+ [NegotiationMode.STARTUP_PITCH]: {
+ highWeight: [NegotiationPattern.BUDGET_OBJECTION, NegotiationPattern.DEFLECTION],
+ mediumWeight: [NegotiationPattern.TIME_PRESSURE, NegotiationPattern.POSITIVE_SIGNAL],
+ lowWeight: [NegotiationPattern.STRENGTH_SIGNAL],
+ },
+ [NegotiationMode.SALES]: {
+ highWeight: [NegotiationPattern.BUDGET_OBJECTION, NegotiationPattern.TIME_PRESSURE, NegotiationPattern.COMMITMENT_LANGUAGE],
+ mediumWeight: [NegotiationPattern.AUTHORITY_PRESSURE, NegotiationPattern.DEFLECTION],
+ lowWeight: [NegotiationPattern.STRENGTH_SIGNAL],
+ },
+ [NegotiationMode.INVESTOR_MEETING]: {
+ highWeight: [NegotiationPattern.DEFLECTION, NegotiationPattern.BUDGET_OBJECTION, NegotiationPattern.NEGATIVE_SIGNAL],
+ mediumWeight: [NegotiationPattern.TIME_PRESSURE, NegotiationPattern.AUTHORITY_PRESSURE],
+ lowWeight: [NegotiationPattern.STRENGTH_SIGNAL],
+ },
+ [NegotiationMode.CLIENT_NEGOTIATION]: {
+ highWeight: [NegotiationPattern.ANCHORING, NegotiationPattern.BUDGET_OBJECTION, NegotiationPattern.TIME_PRESSURE],
+ mediumWeight: [NegotiationPattern.AUTHORITY_PRESSURE, NegotiationPattern.DEFLECTION],
+ lowWeight: [NegotiationPattern.STRENGTH_SIGNAL],
+ },
+ [NegotiationMode.CUSTOM_SCENARIO]: {
+ highWeight: [NegotiationPattern.ANCHORING, NegotiationPattern.DEFLECTION, NegotiationPattern.NEGATIVE_SIGNAL],
+ mediumWeight: [NegotiationPattern.TIME_PRESSURE, NegotiationPattern.BUDGET_OBJECTION],
+ lowWeight: [NegotiationPattern.STRENGTH_SIGNAL],
+ },
+};
+
+/**
+ * Legacy interface for Type Compiling (to prevent UI from breaking).
+ * Used generically to fetch icons and names but not used in the Universal NLP pipeline itself.
+ */
+export const MODE_CONFIGS: Record = {
+ [NegotiationMode.JOB_INTERVIEW]: {
+ mode: NegotiationMode.JOB_INTERVIEW,
+ displayName: 'Job Interview',
+ description: 'Optimize for employment negotiations and interview scenarios',
+ icon: 'đŧ',
+ patternWeights: {} as any,
+ },
+ [NegotiationMode.SALES]: {
+ mode: NegotiationMode.SALES,
+ displayName: 'Sales',
+ description: 'Optimize for sales conversations and client negotiations',
+ icon: 'đ°',
+ patternWeights: {} as any,
+ },
+ [NegotiationMode.STARTUP_PITCH]: {
+ mode: NegotiationMode.STARTUP_PITCH,
+ displayName: 'Startup Pitch',
+ description: 'Optimize for investor pitches and funding negotiations',
+ icon: 'đ',
+ patternWeights: {} as any,
+ },
+ [NegotiationMode.SALARY_RAISE]: {
+ mode: NegotiationMode.SALARY_RAISE,
+ displayName: 'Salary Raise',
+ description: 'Optimize for salary negotiation and raise discussions',
+ icon: 'đ',
+ patternWeights: {} as any,
+ },
+ [NegotiationMode.INVESTOR_MEETING]: {
+ mode: NegotiationMode.INVESTOR_MEETING,
+ displayName: 'Investor Meeting',
+ description: 'Optimize for venture capital and angel funding rounds',
+ icon: 'đĻ',
+ patternWeights: {} as any,
+ },
+ [NegotiationMode.CLIENT_NEGOTIATION]: {
+ mode: NegotiationMode.CLIENT_NEGOTIATION,
+ displayName: 'Client Negotiation',
+ description: 'Optimize for contract, scoping, and B2B client delivery discussions',
+ icon: 'đ¤',
+ patternWeights: {} as any,
+ },
+ [NegotiationMode.CUSTOM_SCENARIO]: {
+ mode: NegotiationMode.CUSTOM_SCENARIO,
+ displayName: 'Custom Scenario',
+ description: 'Optimize for unmapped, highly specific personal negotiations',
+ icon: 'đ¯',
+ patternWeights: {} as any,
+ },
+};
+
+export const getAllPatterns = (): PatternDefinition[] => Object.values(PATTERN_LIBRARY);
+export const getPatternDefinition = (pattern: NegotiationPattern): PatternDefinition => PATTERN_LIBRARY[pattern];
+export const getModeConfig = (mode: NegotiationMode): ModeConfig => MODE_CONFIGS[mode];
+export const getAllModes = (): ModeConfig[] => Object.values(MODE_CONFIGS);
+
+export const FILLER_WORDS = ['um', 'uh', 'like', 'you know', 'basically', 'actually', 'literally', 'just', 'right', 'okay', 'so', 'well', 'anyway'];
+export const FILLER_WORD_PATTERN = new RegExp(`\\b(${FILLER_WORDS.join('|')})\\b`, 'gi');
diff --git a/src/ai/scoringEngine.ts b/src/ai/scoringEngine.ts
new file mode 100644
index 0000000..bf9157a
--- /dev/null
+++ b/src/ai/scoringEngine.ts
@@ -0,0 +1,205 @@
+/**
+ * đ PRIVACY NOTICE
+ * All scoring calculations run locally on device.
+ * No data leaves this device.
+ */
+
+import { CognitiveMetrics } from '../types/session';
+import { FILLER_WORD_PATTERN } from './patternLibrary';
+
+/**
+ * Calculate confidence score based on multiple factors
+ */
+export const calculateConfidenceScore = (
+ baseConfidence: number,
+ factors: {
+ keywordMatches: number;
+ contextMatches: number;
+ patternWeight: number;
+ sensitivityMultiplier: number;
+ }
+): number => {
+ // Base confidence from pattern definition
+ let score = baseConfidence;
+
+ // Boost confidence based on keyword matches (up to +20)
+ const keywordBoost = Math.min(factors.keywordMatches * 5, 20);
+ score += keywordBoost;
+
+ // Boost confidence based on context clues (up to +10)
+ const contextBoost = Math.min(factors.contextMatches * 3, 10);
+ score += contextBoost;
+
+ // Apply mode-specific pattern weight
+ score *= factors.patternWeight;
+
+ // Apply user sensitivity setting (0.5 - 1.5 range)
+ score *= factors.sensitivityMultiplier;
+
+ // Clamp to 0-100 range
+ return Math.max(0, Math.min(100, Math.round(score)));
+};
+
+/**
+ * Calculate focus score from cognitive metrics
+ * Formula: 100 - (gapPenalty * 0.3 + fillerPenalty * 0.4 + ratePenalty * 0.3)
+ */
+export const calculateFocusScore = (
+ metrics: Partial,
+ sessionDuration: number // in milliseconds
+): number => {
+ const durationMinutes = sessionDuration / 60000;
+
+ // Avoid division by zero
+ if (durationMinutes < 0.1) {
+ return 100;
+ }
+
+ // Calculate speech gaps penalty (0-40 points)
+ // More than 5 gaps per minute is concerning
+ const speechGaps = metrics.speechGaps || 0;
+ const gapsPerMinute = speechGaps / durationMinutes;
+ const gapPenalty = Math.min((gapsPerMinute / 5) * 40, 40);
+
+ // Calculate filler words penalty (0-50 points)
+ // More than 10 filler words per minute is concerning
+ const fillerWords = metrics.fillerWords || 0;
+ const fillersPerMinute = fillerWords / durationMinutes;
+ const fillerPenalty = Math.min((fillersPerMinute / 10) * 50, 50);
+
+ // Calculate speech rate penalty (0-30 points)
+ // Normal speech: 120-150 WPM, too slow (<80) or too fast (>200) is concerning
+ const avgSpeechRate = metrics.avgSpeechRate || 130;
+ let ratePenalty = 0;
+ if (avgSpeechRate < 80) {
+ ratePenalty = ((80 - avgSpeechRate) / 80) * 30;
+ } else if (avgSpeechRate > 200) {
+ ratePenalty = ((avgSpeechRate - 200) / 100) * 30;
+ }
+ ratePenalty = Math.min(ratePenalty, 30);
+
+ // Calculate final focus score
+ const focusScore = 100 - (gapPenalty * 0.3 + fillerPenalty * 0.4 + ratePenalty * 0.3);
+
+ return Math.max(0, Math.min(100, Math.round(focusScore)));
+};
+
+/**
+ * Analyze text for filler words
+ */
+export const countFillerWords = (text: string): number => {
+ const matches = text.match(FILLER_WORD_PATTERN);
+ return matches ? matches.length : 0;
+};
+
+/**
+ * Detect speech gaps in transcript chunks
+ * Returns count of gaps > 2 seconds
+ */
+export const detectSpeechGaps = (
+ chunks: Array<{ timestamp: number; text: string }>,
+ gapThreshold: number = 2000 // 2 seconds in milliseconds
+): number => {
+ let gapCount = 0;
+
+ for (let i = 1; i < chunks.length; i++) {
+ const timeDiff = chunks[i].timestamp - chunks[i - 1].timestamp;
+ // If gap is larger than threshold and previous chunk had text
+ if (timeDiff > gapThreshold && chunks[i - 1].text.trim().length > 0) {
+ gapCount++;
+ }
+ }
+
+ return gapCount;
+};
+
+/**
+ * Calculate words per minute from transcript
+ */
+export const calculateSpeechRate = (
+ totalWords: number,
+ speechDuration: number // in milliseconds
+): number => {
+ const durationMinutes = speechDuration / 60000;
+ if (durationMinutes === 0) return 0;
+ return Math.round(totalWords / durationMinutes);
+};
+
+/**
+ * Count total words in text
+ */
+export const countWords = (text: string): number => {
+ return text
+ .trim()
+ .split(/\s+/)
+ .filter((word) => word.length > 0).length;
+};
+
+/**
+ * Calculate cognitive metrics from transcript chunks
+ */
+export const calculateCognitiveMetrics = (
+ chunks: Array<{ timestamp: number; text: string }>,
+ sessionDuration: number
+): CognitiveMetrics => {
+ // Combine all text
+ const fullText = chunks.map((c) => c.text).join(' ');
+
+ // Count metrics
+ const totalWords = countWords(fullText);
+ const fillerWords = countFillerWords(fullText);
+ const speechGaps = detectSpeechGaps(chunks);
+
+ // Calculate speech duration (total session time minus gaps)
+ let speechDuration = sessionDuration;
+ for (let i = 1; i < chunks.length; i++) {
+ const timeDiff = chunks[i].timestamp - chunks[i - 1].timestamp;
+ if (timeDiff > 2000) {
+ speechDuration -= timeDiff - 2000; // Subtract gap time beyond 2s
+ }
+ }
+ speechDuration = Math.max(speechDuration, 1000); // Minimum 1 second
+
+ const avgSpeechRate = calculateSpeechRate(totalWords, speechDuration);
+ const focusScore = calculateFocusScore(
+ { speechGaps, fillerWords, avgSpeechRate, totalWords, speechDuration },
+ sessionDuration
+ );
+
+ return {
+ focusScore,
+ speechGaps,
+ fillerWords,
+ avgSpeechRate,
+ totalWords,
+ speechDuration,
+ };
+};
+
+/**
+ * Determine pattern severity based on confidence score
+ */
+export const determineSeverity = (confidenceScore: number): 'low' | 'medium' | 'high' => {
+ if (confidenceScore >= 80) return 'high';
+ if (confidenceScore >= 60) return 'medium';
+ return 'low';
+};
+
+/**
+ * Calculate leverage indicator (Low/Medium/High)
+ * Based on ratio of positive to negative signals
+ */
+export const calculateLeverageLevel = (
+ positiveSignals: number,
+ negativeSignals: number,
+ totalPatterns: number
+): 'low' | 'medium' | 'high' => {
+ if (totalPatterns === 0) return 'medium';
+
+ const positiveRatio = positiveSignals / totalPatterns;
+ const negativeRatio = negativeSignals / totalPatterns;
+
+ if (positiveRatio > negativeRatio * 2) return 'high';
+ if (negativeRatio > positiveRatio * 2) return 'low';
+ return 'medium';
+};
diff --git a/src/components/AudioVisualizer.tsx b/src/components/AudioVisualizer.tsx
deleted file mode 100644
index ea78d8b..0000000
--- a/src/components/AudioVisualizer.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import React from 'react';
-import { View, StyleSheet } from 'react-native';
-import { AppColors } from '../theme';
-
-interface AudioVisualizerProps {
- level: number; // 0.0 to 1.0
- barCount?: number;
-}
-
-export const AudioVisualizer: React.FC = ({
- level,
- barCount = 7,
-}) => {
- const bars = Array.from({ length: barCount }, (_, i) => {
- // Create wave effect
- const waveEffect = Math.sin((i / barCount) * Math.PI);
- const height = Math.max(0.2, level * waveEffect);
- return height;
- });
-
- return (
-
- {bars.map((height, index) => (
-
- ))}
-
- );
-};
-
-const styles = StyleSheet.create({
- container: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- height: 60,
- gap: 6,
- },
- bar: {
- width: 6,
- backgroundColor: AppColors.accentViolet,
- borderRadius: 3,
- minHeight: 12,
- },
-});
diff --git a/src/components/ChatMessageBubble.tsx b/src/components/ChatMessageBubble.tsx
deleted file mode 100644
index 63780a6..0000000
--- a/src/components/ChatMessageBubble.tsx
+++ /dev/null
@@ -1,136 +0,0 @@
-import React from 'react';
-import { View, Text, StyleSheet } from 'react-native';
-import { AppColors } from '../theme';
-
-export interface ChatMessage {
- text: string;
- isUser: boolean;
- timestamp: Date;
- tokensPerSecond?: number;
- totalTokens?: number;
- isError?: boolean;
- wasCancelled?: boolean;
-}
-
-interface ChatMessageBubbleProps {
- message: ChatMessage;
- isStreaming?: boolean;
-}
-
-export const ChatMessageBubble: React.FC = ({
- message,
- isStreaming = false,
-}) => {
- const { text, isUser, tokensPerSecond, totalTokens, isError, wasCancelled } = message;
-
- return (
-
-
-
- {text}
-
-
- {!isUser && !isStreaming && (tokensPerSecond || totalTokens) && (
-
- {tokensPerSecond && (
-
- ⥠{tokensPerSecond.toFixed(1)} tok/s
-
- )}
- {totalTokens && (
- đ {totalTokens} tokens
- )}
-
- )}
-
- {wasCancelled && (
- â ī¸ Generation cancelled
- )}
-
- {isStreaming && â}
-
-
- );
-};
-
-const styles = StyleSheet.create({
- container: {
- marginVertical: 4,
- paddingHorizontal: 16,
- },
- userContainer: {
- alignItems: 'flex-end',
- },
- assistantContainer: {
- alignItems: 'flex-start',
- },
- bubble: {
- maxWidth: '85%',
- padding: 12,
- borderRadius: 16,
- marginVertical: 2,
- },
- userBubble: {
- backgroundColor: AppColors.accentCyan,
- borderBottomRightRadius: 4,
- },
- assistantBubble: {
- backgroundColor: AppColors.surfaceCard,
- borderBottomLeftRadius: 4,
- borderWidth: 1,
- borderColor: AppColors.textMuted + '20',
- },
- errorBubble: {
- backgroundColor: AppColors.error + '20',
- borderColor: AppColors.error + '40',
- },
- text: {
- fontSize: 15,
- lineHeight: 21,
- },
- userText: {
- color: '#FFFFFF',
- },
- assistantText: {
- color: AppColors.textPrimary,
- },
- errorText: {
- color: AppColors.error,
- },
- metricsContainer: {
- flexDirection: 'row',
- marginTop: 8,
- gap: 12,
- },
- metrics: {
- fontSize: 11,
- color: AppColors.textMuted,
- },
- cancelledText: {
- fontSize: 11,
- color: AppColors.warning,
- marginTop: 4,
- },
- streamingIndicator: {
- fontSize: 16,
- color: AppColors.accentCyan,
- marginTop: 2,
- },
-});
diff --git a/src/components/CircularScore.tsx b/src/components/CircularScore.tsx
new file mode 100644
index 0000000..8685e4a
--- /dev/null
+++ b/src/components/CircularScore.tsx
@@ -0,0 +1,106 @@
+import React from 'react';
+import { View, Text, StyleSheet } from 'react-native';
+import Svg, { Circle } from 'react-native-svg';
+
+interface CircularScoreProps {
+ /** Score value 0â100 */
+ score: number;
+ /** Outer diameter in dp (default 120) */
+ size?: number;
+ /** Stroke width of the donut ring (default 10) */
+ strokeWidth?: number;
+ /** Color of the progress arc */
+ progressColor?: string;
+ /** Color of the background track */
+ trackColor?: string;
+}
+
+const CircularScore: React.FC = ({
+ score,
+ size = 120,
+ strokeWidth = 10,
+ progressColor = '#FFA726',
+ trackColor = 'rgba(255,255,255,0.25)',
+}) => {
+ const radius = (size - strokeWidth) / 2;
+ const circumference = 2 * Math.PI * radius;
+ const clampedScore = Math.min(100, Math.max(0, score));
+ const strokeDashoffset = circumference * (1 - clampedScore / 100);
+
+ // Responsive font sizing based on the donut size
+ const fontSize = Math.round(size * 0.28);
+ const percentFontSize = Math.round(fontSize * 0.55);
+ const lineHeight = Math.round(fontSize * 1.1);
+
+ return (
+
+ {/* SVG donut ring */}
+
+
+ {/* Overlay â perfectly centred text */}
+
+
+ {clampedScore}
+
+ %
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ wrapper: {
+ position: 'relative',
+ },
+ overlay: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ scoreText: {
+ fontWeight: '700',
+ color: '#FFFFFF',
+ textAlign: 'center',
+ // Ensure no hidden padding pushes the text off-centre
+ includeFontPadding: false,
+ textAlignVertical: 'center',
+ },
+});
+
+export { CircularScore };
+export default CircularScore;
diff --git a/src/components/CognitiveMeter.tsx b/src/components/CognitiveMeter.tsx
new file mode 100644
index 0000000..7b66206
--- /dev/null
+++ b/src/components/CognitiveMeter.tsx
@@ -0,0 +1,128 @@
+import React, { useEffect, useRef } from 'react';
+import { View, Text, StyleSheet, Animated } from 'react-native';
+import { AppColors } from '../theme';
+
+interface CognitiveMeterProps {
+ focusScore: number;
+ size?: number;
+ showLabel?: boolean;
+}
+
+export const CognitiveMeter: React.FC = ({ focusScore, size = 100, showLabel = true }) => {
+ const animatedValue = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ Animated.timing(animatedValue, { toValue: focusScore, duration: 1000, useNativeDriver: false }).start();
+ }, [focusScore, animatedValue]);
+
+ const getColor = () => {
+ if (focusScore >= 80) return '#34C759';
+ if (focusScore >= 60) return '#F5A623';
+ return '#FF3B30';
+ };
+
+ const getLabelColor = () => {
+ if (focusScore >= 80) return '#2DA44E';
+ if (focusScore >= 60) return '#D4901A';
+ return '#D32F2F';
+ };
+
+ const getPillBg = () => {
+ if (focusScore >= 80) return 'rgba(52,199,89,0.15)';
+ if (focusScore >= 60) return 'rgba(245,166,35,0.15)';
+ return 'rgba(255,59,48,0.15)';
+ };
+
+ const getLabel = () => {
+ if (focusScore >= 80) return 'High Focus';
+ if (focusScore >= 60) return 'Moderate';
+ return 'Low Focus';
+ };
+
+ const scoreFontSize = Math.round(size * 0.28);
+ const percentFontSize = Math.round(size * 0.16);
+
+ return (
+
+ {/* Circle container â fixed size for the donut */}
+
+ {/* Background track */}
+
+ {/* Animated progress arc */}
+
+ {/* Center text overlay */}
+
+
+
+ {Math.round(focusScore)}
+
+
+ %
+
+
+
+
+
+ {/* Status label */}
+ {showLabel && (
+
+ {getLabel()}
+
+ )}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ wrapper: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: 12,
+ },
+ circleContainer: {
+ position: 'relative',
+ },
+ circle: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ },
+ overlay: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ scoreRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ scoreText: {
+ fontWeight: '700',
+ color: '#FFFFFF',
+ includeFontPadding: false,
+ textAlignVertical: 'center',
+ },
+ percentText: {
+ fontWeight: '600',
+ color: 'rgba(255,255,255,0.7)',
+ includeFontPadding: false,
+ textAlignVertical: 'center',
+ marginLeft: 1,
+ },
+ statusText: {
+ marginTop: 8,
+ fontSize: 15,
+ fontWeight: '600',
+ letterSpacing: 0.5,
+ textAlign: 'center',
+ includeFontPadding: false,
+ },
+});
diff --git a/src/components/CounterStrategyCard.tsx b/src/components/CounterStrategyCard.tsx
new file mode 100644
index 0000000..b6ced0e
--- /dev/null
+++ b/src/components/CounterStrategyCard.tsx
@@ -0,0 +1,233 @@
+import React, { useEffect, useRef } from 'react';
+import { View, Text, StyleSheet, Animated } from 'react-native';
+import LinearGradient from 'react-native-linear-gradient';
+import { CounterStrategy } from '../types/session';
+import { AppColors } from '../theme';
+
+interface CounterStrategyCardProps {
+ strategy: CounterStrategy;
+ onDismiss?: () => void;
+}
+
+/**
+ * CounterStrategyCard - Animated card displaying counter-strategy suggestions
+ * when a negotiation tactic is detected with high confidence.
+ */
+export const CounterStrategyCard: React.FC = ({
+ strategy,
+ onDismiss,
+}) => {
+ const slideAnim = useRef(new Animated.Value(120)).current;
+ const fadeAnim = useRef(new Animated.Value(0)).current;
+ const scaleAnim = useRef(new Animated.Value(0.95)).current;
+
+ useEffect(() => {
+ // Reset animations for new strategy
+ slideAnim.setValue(120);
+ fadeAnim.setValue(0);
+ scaleAnim.setValue(0.95);
+
+ // Animate in: slide up + fade in + scale up
+ Animated.parallel([
+ Animated.spring(slideAnim, {
+ toValue: 0,
+ tension: 60,
+ friction: 9,
+ useNativeDriver: true,
+ }),
+ Animated.timing(fadeAnim, {
+ toValue: 1,
+ duration: 350,
+ useNativeDriver: true,
+ }),
+ Animated.spring(scaleAnim, {
+ toValue: 1,
+ tension: 60,
+ friction: 8,
+ useNativeDriver: true,
+ }),
+ ]).start();
+ }, [strategy.timestamp, slideAnim, fadeAnim, scaleAnim]);
+
+ const getConfidenceColor = () => {
+ if (strategy.confidence >= 85) return AppColors.error;
+ if (strategy.confidence >= 70) return AppColors.warning;
+ return AppColors.accentCyan;
+ };
+
+ const getGradientColors = (): string[] => {
+ if (strategy.confidence >= 85) return ['#DC262620', '#0A0E1A'];
+ if (strategy.confidence >= 70) return ['#F59E0B20', '#0A0E1A'];
+ return ['#00D9FF20', '#0A0E1A'];
+ };
+
+ return (
+
+
+ {/* Header: Tactic Name + Confidence Badge */}
+
+
+
+ â ī¸
+ Tactic Detected
+
+
+
+
+ {Math.round(strategy.confidence)}%
+
+
+
+
+ {strategy.tacticDisplayName}
+
+
+ {/* Divider */}
+
+
+ {/* Counter Suggestions */}
+
+ đĄī¸ Counter Actions
+ {strategy.suggestions.map((suggestion, index) => (
+
+
+ {suggestion}
+
+ ))}
+
+
+ {/* Explanation */}
+
+ {strategy.explanation}
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ marginHorizontal: 16,
+ marginBottom: 12,
+ borderRadius: 16,
+ overflow: 'hidden',
+ elevation: 10,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 6 },
+ shadowOpacity: 0.35,
+ shadowRadius: 10,
+ borderWidth: 1,
+ borderColor: AppColors.accentViolet + '30',
+ },
+ gradient: {
+ padding: 16,
+ },
+ header: {
+ marginBottom: 12,
+ },
+ titleRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: 8,
+ },
+ tacticLabelContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ warningIcon: {
+ fontSize: 14,
+ marginRight: 6,
+ },
+ tacticLabel: {
+ fontSize: 12,
+ fontWeight: '600',
+ color: AppColors.textMuted,
+ textTransform: 'uppercase',
+ letterSpacing: 1,
+ },
+ confidenceBadge: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 10,
+ paddingVertical: 4,
+ borderRadius: 12,
+ },
+ confidenceDot: {
+ width: 6,
+ height: 6,
+ borderRadius: 3,
+ marginRight: 6,
+ },
+ confidenceText: {
+ fontSize: 13,
+ fontWeight: '700',
+ },
+ tacticName: {
+ fontSize: 20,
+ fontWeight: '800',
+ color: AppColors.textPrimary,
+ letterSpacing: 0.3,
+ },
+ divider: {
+ height: 1,
+ backgroundColor: AppColors.textMuted + '20',
+ marginBottom: 12,
+ },
+ suggestionsContainer: {
+ marginBottom: 12,
+ },
+ suggestionsLabel: {
+ fontSize: 13,
+ fontWeight: '700',
+ color: AppColors.accentCyan,
+ marginBottom: 10,
+ letterSpacing: 0.5,
+ },
+ suggestionRow: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ marginBottom: 8,
+ paddingLeft: 4,
+ },
+ bulletDot: {
+ width: 6,
+ height: 6,
+ borderRadius: 3,
+ backgroundColor: AppColors.accentViolet,
+ marginTop: 7,
+ marginRight: 10,
+ },
+ suggestionText: {
+ fontSize: 14,
+ color: AppColors.textPrimary,
+ lineHeight: 20,
+ flex: 1,
+ },
+ explanationContainer: {
+ backgroundColor: AppColors.surfaceCard + 'AA',
+ borderRadius: 10,
+ padding: 12,
+ borderLeftWidth: 3,
+ borderLeftColor: AppColors.accentViolet + '80',
+ },
+ explanationText: {
+ fontSize: 12,
+ color: AppColors.textSecondary,
+ lineHeight: 18,
+ fontStyle: 'italic',
+ },
+});
diff --git a/src/components/FeatureCard.tsx b/src/components/FeatureCard.tsx
deleted file mode 100644
index 01f7c1f..0000000
--- a/src/components/FeatureCard.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-import React from 'react';
-import {
- TouchableOpacity,
- Text,
- StyleSheet,
- ViewStyle,
-} from 'react-native';
-import LinearGradient from 'react-native-linear-gradient';
-import { AppColors } from '../theme';
-
-interface FeatureCardProps {
- title: string;
- subtitle: string;
- icon: string;
- gradientColors: string[];
- onPress: () => void;
-}
-
-export const FeatureCard: React.FC = ({
- title,
- subtitle,
- gradientColors,
- onPress,
-}) => {
- return (
-
-
- {getIconEmoji(title)}
- {title}
- {subtitle}
-
-
- );
-};
-
-// Helper to get emoji icon based on title
-const getIconEmoji = (title: string): string => {
- const iconMap: Record = {
- Chat: 'đŦ',
- Tools: 'đ ',
- Speech: 'đ¤',
- Voice: 'đ',
- Pipeline: 'â¨',
- };
- return iconMap[title] || 'âĄ';
-};
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- margin: 8,
- borderRadius: 20,
- overflow: 'hidden',
- elevation: 8,
- shadowColor: AppColors.accentCyan,
- shadowOffset: { width: 0, height: 4 },
- shadowOpacity: 0.3,
- shadowRadius: 12,
- } as ViewStyle,
- gradient: {
- padding: 20,
- minHeight: 160,
- justifyContent: 'center',
- alignItems: 'center',
- } as ViewStyle,
- icon: {
- fontSize: 48,
- marginBottom: 12,
- },
- title: {
- fontSize: 20,
- fontWeight: '700',
- color: '#FFFFFF',
- marginBottom: 4,
- textAlign: 'center',
- },
- subtitle: {
- fontSize: 13,
- color: 'rgba(255, 255, 255, 0.85)',
- textAlign: 'center',
- },
-});
diff --git a/src/components/LiveTranscript.tsx b/src/components/LiveTranscript.tsx
new file mode 100644
index 0000000..a46a4e6
--- /dev/null
+++ b/src/components/LiveTranscript.tsx
@@ -0,0 +1,65 @@
+import React, { useRef, useEffect } from 'react';
+import { View, Text, StyleSheet, FlatList, Animated } from 'react-native';
+import { TranscriptChunk } from '../types/session';
+import { AppColors } from '../theme';
+
+interface LiveTranscriptProps {
+ transcript: TranscriptChunk[];
+ highlightPatterns?: boolean;
+}
+
+export const LiveTranscript: React.FC = ({ transcript, highlightPatterns = true }) => {
+ const flatListRef = useRef(null);
+ const fadeAnim = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ if (transcript.length > 0) {
+ flatListRef.current?.scrollToEnd({ animated: true });
+ Animated.timing(fadeAnim, { toValue: 1, duration: 300, useNativeDriver: true }).start();
+ }
+ }, [transcript.length, fadeAnim]);
+
+ const formatTime = (timestamp: number) =>
+ new Date(timestamp).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
+
+ const renderItem = ({ item, index }: { item: TranscriptChunk; index: number }) => (
+
+ {formatTime(item.timestamp)}
+ {item.text}
+ {item.hasPattern && highlightPatterns && (
+ Pattern Detected
+ )}
+
+ );
+
+ if (transcript.length === 0) return (
+
+ đ¤
+ Start speaking...
+ Your transcript will appear here
+
+ );
+
+ return (
+ i.id}
+ style={styles.list} contentContainerStyle={styles.contentContainer}
+ showsVerticalScrollIndicator indicatorStyle="default"
+ onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: true })} />
+ );
+};
+
+const styles = StyleSheet.create({
+ list: { flex: 1 },
+ contentContainer: { padding: 16 },
+ item: { marginBottom: 12, padding: 14, backgroundColor: '#FFFFFF', borderRadius: 16, borderLeftWidth: 3, borderLeftColor: '#DDD6FE', elevation: 1, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.03, shadowRadius: 3 },
+ itemHighlighted: { backgroundColor: '#F5F0FF', borderLeftColor: '#7B61FF', borderLeftWidth: 4 },
+ timestamp: { fontSize: 11, color: AppColors.textMuted, marginBottom: 6, fontWeight: '500' },
+ text: { fontSize: 15, color: AppColors.textPrimary, lineHeight: 22 },
+ patternBadge: { marginTop: 8, paddingVertical: 4, paddingHorizontal: 10, backgroundColor: '#EDE9FE', borderRadius: 8, alignSelf: 'flex-start' },
+ patternBadgeText: { fontSize: 11, color: '#7B61FF', fontWeight: '600' },
+ emptyContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32 },
+ emptyIconCircle: { width: 80, height: 80, borderRadius: 28, backgroundColor: '#EDE9FE', justifyContent: 'center', alignItems: 'center', marginBottom: 20 },
+ emptyIcon: { fontSize: 36 },
+ emptyText: { fontSize: 18, fontWeight: '600', color: AppColors.textPrimary, marginBottom: 8 },
+ emptySubtext: { fontSize: 14, color: AppColors.textSecondary, textAlign: 'center' },
+});
diff --git a/src/components/ModelLoaderWidget.tsx b/src/components/ModelLoaderWidget.tsx
deleted file mode 100644
index dc690b3..0000000
--- a/src/components/ModelLoaderWidget.tsx
+++ /dev/null
@@ -1,185 +0,0 @@
-import React from 'react';
-import {
- View,
- Text,
- TouchableOpacity,
- ActivityIndicator,
- StyleSheet,
-} from 'react-native';
-import { AppColors } from '../theme';
-
-interface ModelLoaderWidgetProps {
- title: string;
- subtitle: string;
- icon: string;
- accentColor: string;
- isDownloading: boolean;
- isLoading: boolean;
- progress: number;
- onLoad: () => void;
-}
-
-export const ModelLoaderWidget: React.FC = ({
- title,
- subtitle,
- accentColor,
- isDownloading,
- isLoading,
- progress,
- onLoad,
-}) => {
- const getIconEmoji = () => {
- if (title.includes('LLM')) return 'đ¤';
- if (title.includes('STT')) return 'đ¤';
- if (title.includes('TTS')) return 'đ';
- if (title.includes('Voice')) return 'â¨';
- return 'đĻ';
- };
-
- return (
-
-
-
- {getIconEmoji()}
-
-
- {title}
- {subtitle}
-
- {(isDownloading || isLoading) && (
-
-
-
- {isDownloading
- ? `Downloading... ${Math.round(progress)}%`
- : 'Loading model...'}
-
- {isDownloading && (
-
-
-
- )}
-
- )}
-
- {!isDownloading && !isLoading && (
-
- Download & Load Model
-
- )}
-
-
-
- đ All processing happens on your device. Your data never leaves your phone.
-
-
-
-
- );
-};
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: AppColors.primaryDark,
- justifyContent: 'center',
- alignItems: 'center',
- padding: 24,
- },
- content: {
- maxWidth: 400,
- alignItems: 'center',
- },
- iconContainer: {
- width: 100,
- height: 100,
- borderRadius: 50,
- justifyContent: 'center',
- alignItems: 'center',
- marginBottom: 24,
- },
- iconEmoji: {
- fontSize: 56,
- },
- title: {
- fontSize: 24,
- fontWeight: '700',
- color: AppColors.textPrimary,
- marginBottom: 8,
- textAlign: 'center',
- },
- subtitle: {
- fontSize: 14,
- color: AppColors.textSecondary,
- textAlign: 'center',
- marginBottom: 32,
- lineHeight: 20,
- },
- loadingContainer: {
- alignItems: 'center',
- marginVertical: 24,
- },
- loadingText: {
- marginTop: 16,
- fontSize: 14,
- color: AppColors.textSecondary,
- },
- progressBarContainer: {
- width: 200,
- height: 6,
- backgroundColor: AppColors.surfaceCard,
- borderRadius: 3,
- marginTop: 12,
- overflow: 'hidden',
- },
- progressBar: {
- height: '100%',
- borderRadius: 3,
- },
- button: {
- paddingHorizontal: 32,
- paddingVertical: 16,
- borderRadius: 12,
- elevation: 4,
- shadowColor: '#000',
- shadowOffset: { width: 0, height: 4 },
- shadowOpacity: 0.3,
- shadowRadius: 8,
- minWidth: 220,
- alignItems: 'center',
- justifyContent: 'center',
- alignSelf: 'center',
- },
- buttonText: {
- fontSize: 16,
- fontWeight: '700',
- color: '#FFFFFF',
- textAlign: 'center',
- },
- infoBox: {
- marginTop: 32,
- padding: 16,
- backgroundColor: AppColors.surfaceCard,
- borderRadius: 12,
- borderWidth: 1,
- borderColor: AppColors.textMuted + '20',
- },
- infoText: {
- fontSize: 12,
- color: AppColors.textSecondary,
- textAlign: 'center',
- lineHeight: 18,
- },
-});
diff --git a/src/components/SessionSummaryCard.tsx b/src/components/SessionSummaryCard.tsx
new file mode 100644
index 0000000..03d9bbe
--- /dev/null
+++ b/src/components/SessionSummaryCard.tsx
@@ -0,0 +1,125 @@
+import React from 'react';
+import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
+import { Session } from '../types/session';
+import { AppColors } from '../theme';
+import { getModeConfig } from '../ai/patternLibrary';
+
+interface SessionSummaryCardProps {
+ session: Session;
+ onPress: () => void;
+}
+
+/**
+ * SessionSummaryCard - Transaction-style session card (like the reference image)
+ */
+export const SessionSummaryCard: React.FC = ({ session, onPress }) => {
+ const formatDate = (timestamp: number): string => {
+ const date = new Date(timestamp);
+ const now = new Date();
+ const diff = now.getTime() - date.getTime();
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
+ if (days === 0) {
+ return 'Today, ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
+ } else if (days === 1) {
+ return 'Yesterday, ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
+ } else {
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' +
+ date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
+ }
+ };
+
+ const formatDuration = (ms: number): string => {
+ const minutes = Math.floor(ms / 60000);
+ return `${minutes}min`;
+ };
+
+ const getFocusColor = (score: number): string => {
+ if (score >= 80) return '#34C759';
+ if (score >= 60) return '#FF9F0A';
+ return '#FF3B30';
+ };
+
+ const getIconBg = (index: number): string => {
+ const colors = ['#EDE9FE', '#DCFCE7', '#FEF3C7', '#FCE7F3', '#DBEAFE'];
+ return colors[index % colors.length];
+ };
+
+ const modeConfig = getModeConfig(session.mode);
+
+ return (
+
+ {/* Icon */}
+
+ {modeConfig.icon}
+
+
+ {/* Info */}
+
+ {modeConfig.displayName}
+ {formatDate(session.timestamp)}
+
+
+ {/* Score */}
+
+
+ {Math.round(session.cognitiveMetrics.focusScore)}%
+
+ {formatDuration(session.duration)}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: '#FFFFFF',
+ padding: 16,
+ borderRadius: 18,
+ marginBottom: 10,
+ elevation: 2,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 1 },
+ shadowOpacity: 0.05,
+ shadowRadius: 4,
+ },
+ iconCircle: {
+ width: 48,
+ height: 48,
+ borderRadius: 16,
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginRight: 14,
+ },
+ modeIcon: {
+ fontSize: 22,
+ },
+ info: {
+ flex: 1,
+ },
+ modeName: {
+ fontSize: 16,
+ fontWeight: '600',
+ color: AppColors.textPrimary,
+ marginBottom: 3,
+ },
+ date: {
+ fontSize: 12,
+ color: AppColors.textMuted,
+ fontWeight: '400',
+ },
+ scoreContainer: {
+ alignItems: 'flex-end',
+ },
+ score: {
+ fontSize: 17,
+ fontWeight: '700',
+ marginBottom: 2,
+ },
+ duration: {
+ fontSize: 11,
+ color: AppColors.textMuted,
+ fontWeight: '500',
+ },
+});
diff --git a/src/components/SuggestionCard.tsx b/src/components/SuggestionCard.tsx
new file mode 100644
index 0000000..fcefc16
--- /dev/null
+++ b/src/components/SuggestionCard.tsx
@@ -0,0 +1,66 @@
+import React, { useEffect, useRef } from 'react';
+import { View, Text, StyleSheet, Animated } from 'react-native';
+import LinearGradient from 'react-native-linear-gradient';
+import { DetectedPattern } from '../types/session';
+import { AppColors } from '../theme';
+import { getPatternDefinition } from '../ai/patternLibrary';
+
+interface SuggestionCardProps {
+ pattern: DetectedPattern;
+ onDismiss?: () => void;
+}
+
+export const SuggestionCard: React.FC = ({ pattern }) => {
+ const slideAnim = useRef(new Animated.Value(100)).current;
+ const fadeAnim = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ Animated.parallel([
+ Animated.spring(slideAnim, { toValue: 0, tension: 50, friction: 8, useNativeDriver: true }),
+ Animated.timing(fadeAnim, { toValue: 1, duration: 300, useNativeDriver: true }),
+ ]).start();
+ }, [slideAnim, fadeAnim]);
+
+ const patternDef = getPatternDefinition(pattern.pattern);
+
+ const getSeverityGradient = (): [string, string] => {
+ switch (pattern.severity) {
+ case 'high': return ['#FF3B30', '#C53030'];
+ case 'medium': return ['#FF9F0A', '#D97706'];
+ case 'low': return ['#7B61FF', '#9B82FF'];
+ default: return ['#7B61FF', '#9B82FF'];
+ }
+ };
+
+ return (
+
+
+
+
+ {patternDef.displayName}
+ {Math.round(pattern.confidenceScore)}%
+
+ {patternDef.description}
+
+
+ đĄ Suggestion
+ {pattern.suggestion}
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: { marginHorizontal: 16, marginBottom: 10, borderRadius: 20, overflow: 'hidden', elevation: 6, shadowColor: '#7B61FF', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.25, shadowRadius: 12 },
+ gradient: { padding: 16 },
+ header: { marginBottom: 12 },
+ titleRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 },
+ patternName: { fontSize: 18, fontWeight: '700', color: '#FFFFFF', flex: 1 },
+ badge: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 12, marginLeft: 8, backgroundColor: 'rgba(255,255,255,0.25)' },
+ badgeText: { fontSize: 12, fontWeight: '700', color: '#FFFFFF' },
+ description: { fontSize: 13, color: 'rgba(255,255,255,0.8)', lineHeight: 18 },
+ suggestionBox: { backgroundColor: 'rgba(255,255,255,0.15)', padding: 14, borderRadius: 14 },
+ suggestionLabel: { fontSize: 12, fontWeight: '600', color: 'rgba(255,255,255,0.7)', marginBottom: 4 },
+ suggestionText: { fontSize: 14, color: '#FFFFFF', lineHeight: 20 },
+});
diff --git a/src/components/index.ts b/src/components/index.ts
index 354e4c3..aa84643 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -1,4 +1,6 @@
-export * from './FeatureCard';
-export * from './ModelLoaderWidget';
-export * from './ChatMessageBubble';
-export * from './AudioVisualizer';
+export { LiveTranscript } from './LiveTranscript';
+export { SuggestionCard } from './SuggestionCard';
+export { CognitiveMeter } from './CognitiveMeter';
+export { SessionSummaryCard } from './SessionSummaryCard';
+export { CounterStrategyCard } from './CounterStrategyCard';
+export { CircularScore } from './CircularScore';
diff --git a/src/config/index.ts b/src/config/index.ts
new file mode 100644
index 0000000..71a94e8
--- /dev/null
+++ b/src/config/index.ts
@@ -0,0 +1 @@
+export * from './languages';
diff --git a/src/config/languages.ts b/src/config/languages.ts
new file mode 100644
index 0000000..5d2038f
--- /dev/null
+++ b/src/config/languages.ts
@@ -0,0 +1,121 @@
+/**
+ * Language configuration for multi-language STT/TTS support
+ * Per RunAnywhere docs: https://docs.runanywhere.ai/react-native/stt/options
+ */
+
+export interface LanguageConfig {
+ code: string;
+ name: string;
+ nativeName: string;
+ flag: string;
+ sttModelId?: string;
+ ttsModelId?: string;
+ sttModelUrl?: string;
+ ttsModelUrl?: string;
+}
+
+/**
+ * Supported languages with their model configurations
+ * Models from: https://github.com/RunanywhereAI/sherpa-onnx/releases
+ */
+export const SUPPORTED_LANGUAGES: LanguageConfig[] = [
+ {
+ code: 'en',
+ name: 'English',
+ nativeName: 'English',
+ flag: 'đēđ¸',
+ sttModelId: 'sherpa-onnx-whisper-base.en',
+ ttsModelId: 'vits-piper-en_US-lessac-medium',
+ sttModelUrl:
+ 'https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/sherpa-onnx-whisper-base.en.tar.gz',
+ ttsModelUrl:
+ 'https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/vits-piper-en_US-lessac-medium.tar.gz',
+ },
+ {
+ code: 'es',
+ name: 'Spanish',
+ nativeName: 'EspaÃąol',
+ flag: 'đĒđ¸',
+ sttModelId: 'sherpa-onnx-whisper-tiny-es',
+ ttsModelId: 'vits-piper-es_ES-sharvard-medium',
+ // Note: These are example URLs - actual multilingual models would need to be hosted
+ sttModelUrl:
+ 'https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/sherpa-onnx-whisper-tiny.tar.gz',
+ ttsModelUrl:
+ 'https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/vits-piper-es_ES-sharvard-medium.tar.gz',
+ },
+ {
+ code: 'fr',
+ name: 'French',
+ nativeName: 'Français',
+ flag: 'đĢđˇ',
+ sttModelId: 'sherpa-onnx-whisper-tiny-fr',
+ ttsModelId: 'vits-piper-fr_FR-upmc-medium',
+ sttModelUrl:
+ 'https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/sherpa-onnx-whisper-tiny.tar.gz',
+ ttsModelUrl:
+ 'https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/vits-piper-fr_FR-upmc-medium.tar.gz',
+ },
+ {
+ code: 'de',
+ name: 'German',
+ nativeName: 'Deutsch',
+ flag: 'đŠđĒ',
+ sttModelId: 'sherpa-onnx-whisper-tiny-de',
+ ttsModelId: 'vits-piper-de_DE-thorsten-medium',
+ sttModelUrl:
+ 'https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/sherpa-onnx-whisper-tiny.tar.gz',
+ ttsModelUrl:
+ 'https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/vits-piper-de_DE-thorsten-medium.tar.gz',
+ },
+ {
+ code: 'zh',
+ name: 'Chinese',
+ nativeName: '䏿',
+ flag: 'đ¨đŗ',
+ sttModelId: 'sherpa-onnx-whisper-tiny-zh',
+ ttsModelId: 'vits-piper-zh_CN-huayan-medium',
+ sttModelUrl:
+ 'https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/sherpa-onnx-whisper-tiny.tar.gz',
+ },
+ {
+ code: 'ja',
+ name: 'Japanese',
+ nativeName: 'æĨæŦčĒ',
+ flag: 'đ¯đĩ',
+ sttModelId: 'sherpa-onnx-whisper-tiny-ja',
+ ttsModelId: 'vits-piper-ja_JP-medium',
+ sttModelUrl:
+ 'https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/sherpa-onnx-whisper-tiny.tar.gz',
+ },
+];
+
+/**
+ * Get language configuration by code
+ */
+export const getLanguageByCode = (code: string): LanguageConfig | undefined => {
+ return SUPPORTED_LANGUAGES.find((lang) => lang.code === code);
+};
+
+/**
+ * Get default language (English)
+ */
+export const getDefaultLanguage = (): LanguageConfig => {
+ return SUPPORTED_LANGUAGES[0];
+};
+
+/**
+ * Check if language has STT support
+ */
+export const hasSTTSupport = (code: string): boolean => {
+ const lang = getLanguageByCode(code);
+ return !!(lang?.sttModelId && lang?.sttModelUrl);
+};
+
+/**
+ * Check if language has TTS support
+ */
+export const hasTTSSupport = (code: string): boolean => {
+ const lang = getLanguageByCode(code);
+ return !!(lang?.ttsModelId && lang?.ttsModelUrl);
+};
diff --git a/src/hooks/useCounterStrategy.ts b/src/hooks/useCounterStrategy.ts
new file mode 100644
index 0000000..c420e18
--- /dev/null
+++ b/src/hooks/useCounterStrategy.ts
@@ -0,0 +1,85 @@
+/**
+ * đ PRIVACY NOTICE
+ * All counter-strategy logic runs locally on device.
+ * No data leaves this device.
+ */
+
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { DetectedPattern, CounterStrategy } from '../types/session';
+import { generateCounterStrategies, resetAllCooldowns } from '../services/CounterStrategyEngine';
+
+interface UseCounterStrategyReturn {
+ /** The currently active counter-strategy to display, or null */
+ activeStrategy: CounterStrategy | null;
+ /** Dismiss the current strategy card */
+ dismiss: () => void;
+ /** Reset all cooldowns (e.g., on session start) */
+ reset: () => void;
+}
+
+/**
+ * Hook that processes detected patterns and produces counter-strategy suggestions.
+ *
+ * - Only triggers when confidence > threshold (default 70%)
+ * - Applies 10-second cooldown per tactic
+ * - Only updates UI when tactic actually changes (no flicker)
+ *
+ * @param detectedPatterns - Array of detected patterns from the live session
+ * @param confidenceThreshold - Minimum confidence to trigger (default 70)
+ */
+export const useCounterStrategy = (
+ detectedPatterns: DetectedPattern[],
+ confidenceThreshold: number = 70,
+): UseCounterStrategyReturn => {
+ const [activeStrategy, setActiveStrategy] = useState(null);
+ const lastTacticRef = useRef(null);
+ const lastTimestampRef = useRef(0);
+
+ useEffect(() => {
+ if (detectedPatterns.length === 0) return;
+
+ // Get the highest-confidence pattern (array is sorted by confidence, highest first)
+ const topPattern = detectedPatterns[0];
+
+ if (!topPattern) return;
+
+ console.log('[useCounterStrategy] đ Evaluating top pattern:', topPattern.pattern, 'confidence:', topPattern.confidenceScore, 'id:', topPattern.id);
+
+ // Skip if same pattern ID (exact same detection, prevents flicker)
+ if (topPattern.id === lastTacticRef.current) {
+ console.log('[useCounterStrategy] âī¸ Same pattern ID, skipping');
+ return;
+ }
+
+ // Try to generate counter-strategy (engine handles cooldown internally)
+ const strategy = generateCounterStrategies(
+ topPattern.pattern,
+ topPattern.confidenceScore,
+ 10_000, // 10s cooldown
+ confidenceThreshold,
+ );
+
+ if (strategy) {
+ console.log('[useCounterStrategy] â
New counter-strategy:', strategy.tacticDisplayName);
+ lastTacticRef.current = topPattern.id;
+ lastTimestampRef.current = Date.now();
+ setActiveStrategy(strategy);
+ } else {
+ console.log('[useCounterStrategy] â No strategy generated (cooldown or below threshold)');
+ }
+ }, [detectedPatterns, detectedPatterns.length, confidenceThreshold]);
+
+ const dismiss = useCallback(() => {
+ setActiveStrategy(null);
+ lastTacticRef.current = null;
+ }, []);
+
+ const reset = useCallback(() => {
+ setActiveStrategy(null);
+ lastTacticRef.current = null;
+ lastTimestampRef.current = 0;
+ resetAllCooldowns();
+ }, []);
+
+ return { activeStrategy, dismiss, reset };
+};
diff --git a/src/hooks/useLiveSession.ts b/src/hooks/useLiveSession.ts
new file mode 100644
index 0000000..f181066
--- /dev/null
+++ b/src/hooks/useLiveSession.ts
@@ -0,0 +1,335 @@
+/**
+ * đ PRIVACY NOTICE
+ * All session management runs locally on device.
+ * No data leaves this device. Uses RunAnywhere SDK for on-device inference only.
+ */
+
+/**
+ * useLiveSession â Unified hook for the live session lifecycle.
+ *
+ * Replaces both useLiveTranscription and useCounterStrategy with a single
+ * useReducer + SessionEngine integration.
+ *
+ * WHY DEBOUNCE REDUCES FLICKER:
+ * - Without debounce, tactic analysis runs on every transcript chunk (every few seconds).
+ * - This causes rapid state changes â suggestion cards flash in/out.
+ * - The 250ms debounce waits for a speech pause before triggering analysis,
+ * so suggestions only update after the user finishes a phrase.
+ *
+ * HOW isLiveRef PREVENTS UNMOUNTED UPDATES:
+ * - Async inference (analyzeSession) can resolve AFTER stopSession() is called.
+ * - isLiveRef.current is set to false immediately on stop/cancel.
+ * - All async callbacks check isLiveRef.current before dispatching.
+ * - This prevents "Can't perform state update on unmounted component" errors.
+ */
+
+import { useReducer, useRef, useCallback, useEffect } from 'react';
+import { NegotiationMode, Session, AnalysisResult } from '../types/session';
+import {
+ sessionReducer,
+ createInitialState,
+ SessionState,
+ SessionAction,
+} from '../state/sessionReducer';
+import { SessionEngine } from '../services/SessionEngine';
+import { LocalStorageService } from '../services/LocalStorageService';
+import { resetAllCooldowns } from '../services/CounterStrategyEngine';
+
+// âââââââââââââââââââââââââââ Return Type âââââââââââââââââââââââââââ
+
+export interface UseLiveSessionReturn {
+ /** Current session state (single source of truth) */
+ state: SessionState;
+ /** Whether the session is actively recording */
+ isRecording: boolean;
+ /** Start a new session in the given mode */
+ startSession: (mode: NegotiationMode) => Promise;
+ /** Stop the session and save it */
+ stopSession: () => Promise;
+ /** Cancel the session without saving */
+ cancelSession: () => Promise;
+ /** Most recent error message */
+ error: string | null;
+}
+
+// âââââââââââââââââââââââââââ Constants âââââââââââââââââââââââââââ
+
+/**
+ * WHY 250ms DEBOUNCE:
+ * Tactic inference doesn't need to run for every single word.
+ * We wait 250ms after the last transcript chunk before running analysis.
+ * This ensures analysis only triggers during speech pauses.
+ */
+const ANALYSIS_DEBOUNCE_MS = 250;
+
+// âââââââââââââââââââââââââââ Hook âââââââââââââââââââââââââââ
+
+export const useLiveSession = (): UseLiveSessionReturn => {
+ const [state, dispatch] = useReducer(sessionReducer, createInitialState());
+
+ /**
+ * isLiveRef: Prevents async callbacks from dispatching after session ends.
+ * Set to true on startSession, false on stopSession/cancelSession.
+ * Every async callback checks this before calling dispatch.
+ */
+ const isLiveRef = useRef(false);
+
+ /** SessionEngine handles recording, transcription, and auto-save */
+ const sessionEngineRef = useRef(null);
+
+ /** Duration timer interval */
+ const durationIntervalRef = useRef(null);
+
+ /**
+ * Track the last dispatched transcript chunk ID to avoid duplicates.
+ * The SessionEngine callback fires on every state update (audio level, duration, etc.),
+ * not just new transcripts. Without this, we'd dispatch the same chunk multiple times.
+ */
+ const lastDispatchedChunkIdRef = useRef(null);
+
+ /**
+ * Debounce timer for tactic analysis.
+ * WHY: We don't want to run NegotiationAnalyzer on every transcript chunk.
+ * Instead we wait 250ms after the last chunk, then run analysis once.
+ * This dramatically reduces suggestion flicker.
+ */
+ const analysisDebounceRef = useRef(null);
+
+ // âââ Initialize SessionEngine âââ
+ useEffect(() => {
+ sessionEngineRef.current = new SessionEngine();
+
+ return () => {
+ // Cleanup on unmount
+ if (sessionEngineRef.current) {
+ sessionEngineRef.current.cleanup();
+ }
+ if (durationIntervalRef.current) {
+ clearInterval(durationIntervalRef.current);
+ }
+ if (analysisDebounceRef.current) {
+ clearTimeout(analysisDebounceRef.current);
+ }
+ isLiveRef.current = false;
+ };
+ }, []);
+
+ // âââ Safe Dispatch (checks isLiveRef) âââ
+ const safeDispatch = useCallback((action: SessionAction) => {
+ if (!isLiveRef.current && action.type !== 'RESET') {
+ console.log(
+ `[useLiveSession] â ī¸ Dispatch blocked (session ended): ${action.type}`,
+ );
+ return;
+ }
+ dispatch(action);
+ }, []);
+
+ // âââ Start Session âââ
+ const startSession = useCallback(
+ async (mode: NegotiationMode): Promise => {
+ if (!sessionEngineRef.current) {
+ dispatch({ type: 'SET_ERROR', message: 'Session engine not initialized' });
+ return false;
+ }
+
+ // Reset cooldowns from previous session
+ resetAllCooldowns();
+
+ // Set live flag BEFORE starting anything
+ isLiveRef.current = true;
+
+ // Dispatch START to reducer
+ dispatch({
+ type: 'START_SESSION',
+ mode,
+ startTime: Date.now(),
+ });
+
+ try {
+ // Start SessionEngine â it calls our callbacks for transcript + analysis
+ const started = await sessionEngineRef.current.startSession(
+ mode,
+ (engineState) => {
+ // Guard: don't dispatch if session was stopped
+ if (!isLiveRef.current) {
+ console.log('[useLiveSession] â ī¸ SessionEngine callback blocked â session ended');
+ return;
+ }
+
+ // âââ Sync transcript chunks from engine to reducer âââ
+ // Sync the entire array to handle mutative string concatenations (paragraph building)
+ if (engineState.transcript.length > 0) {
+ const latestChunk =
+ engineState.transcript[engineState.transcript.length - 1];
+
+ // Check if the actual text content changed, since IDs remain the same during concatenation
+ if (latestChunk.text !== lastDispatchedChunkIdRef.current) {
+ lastDispatchedChunkIdRef.current = latestChunk.text;
+
+ // Dispatch the synced transcript array
+ safeDispatch({
+ type: 'SYNC_TRANSCRIPT',
+ transcript: engineState.transcript,
+ });
+ }
+ }
+
+ // âââ Debounced tactic analysis âââ
+ // ALWAYS evaluate the latest intent detection patterns, regardless of string change
+ // since intent resolution is purely asynchronous!
+ if (engineState.detectedPatterns.length > 0) {
+ if (analysisDebounceRef.current) {
+ clearTimeout(analysisDebounceRef.current);
+ }
+
+ analysisDebounceRef.current = setTimeout(() => {
+ if (!isLiveRef.current) return;
+ safeDispatch({
+ type: 'TACTIC_DETECTED',
+ patterns: engineState.detectedPatterns,
+ focusScore: engineState.currentFocusScore,
+ timestampMs: Date.now(),
+ });
+ }, ANALYSIS_DEBOUNCE_MS);
+ }
+
+ // âââ Sync audio level âââ
+ safeDispatch({
+ type: 'UPDATE_AUDIO_LEVEL',
+ level: engineState.audioLevel,
+ });
+ },
+ );
+
+ if (!started) {
+ isLiveRef.current = false;
+ dispatch({
+ type: 'SET_ERROR',
+ message: 'Failed to start recording. Please check microphone permissions.',
+ });
+ return false;
+ }
+
+ // âââ Duration Timer âââ
+ durationIntervalRef.current = setInterval(() => {
+ if (!isLiveRef.current) return;
+ if (sessionEngineRef.current) {
+ sessionEngineRef.current.updateDuration();
+ const engineState = sessionEngineRef.current.getState();
+ safeDispatch({
+ type: 'UPDATE_DURATION',
+ duration: engineState.duration,
+ });
+
+ // Also check for new patterns from continuous analysis
+ if (engineState.detectedPatterns.length > 0) {
+ safeDispatch({
+ type: 'TACTIC_DETECTED',
+ patterns: engineState.detectedPatterns,
+ focusScore: engineState.currentFocusScore,
+ timestampMs: Date.now(),
+ });
+ }
+ }
+ }, 1000);
+
+ console.log('[useLiveSession] â
Session started');
+ return true;
+ } catch (err) {
+ isLiveRef.current = false;
+ const errorMessage =
+ err instanceof Error ? err.message : 'Unknown error';
+ dispatch({
+ type: 'SET_ERROR',
+ message: `Failed to start session: ${errorMessage}`,
+ });
+ return false;
+ }
+ },
+ [safeDispatch],
+ );
+
+ // âââ Stop Session âââ
+ const stopSession = useCallback(async (): Promise => {
+ if (!sessionEngineRef.current) {
+ return null;
+ }
+
+ // Immediately prevent further async dispatches
+ isLiveRef.current = false;
+
+ // Clear timers
+ if (durationIntervalRef.current) {
+ clearInterval(durationIntervalRef.current);
+ durationIntervalRef.current = null;
+ }
+ if (analysisDebounceRef.current) {
+ clearTimeout(analysisDebounceRef.current);
+ analysisDebounceRef.current = null;
+ }
+
+ // Dispatch STOP to reducer
+ dispatch({ type: 'STOP_SESSION' });
+
+ try {
+ const session = await sessionEngineRef.current.stopSession();
+
+ // Reset state for next session
+ dispatch({ type: 'RESET' });
+
+ return session;
+ } catch (err) {
+ const errorMessage =
+ err instanceof Error ? err.message : 'Unknown error';
+ dispatch({
+ type: 'SET_ERROR',
+ message: `Failed to stop session: ${errorMessage}`,
+ });
+ return null;
+ }
+ }, []);
+
+ // âââ Cancel Session âââ
+ const cancelSession = useCallback(async (): Promise => {
+ if (!sessionEngineRef.current) {
+ return;
+ }
+
+ // Immediately prevent further async dispatches
+ isLiveRef.current = false;
+
+ // Clear timers
+ if (durationIntervalRef.current) {
+ clearInterval(durationIntervalRef.current);
+ durationIntervalRef.current = null;
+ }
+ if (analysisDebounceRef.current) {
+ clearTimeout(analysisDebounceRef.current);
+ analysisDebounceRef.current = null;
+ }
+
+ try {
+ await sessionEngineRef.current.cancelSession();
+ } catch (err) {
+ const errorMessage =
+ err instanceof Error ? err.message : 'Unknown error';
+ dispatch({
+ type: 'SET_ERROR',
+ message: `Failed to cancel session: ${errorMessage}`,
+ });
+ }
+
+ // Reset state
+ dispatch({ type: 'RESET' });
+ }, []);
+
+ return {
+ state,
+ isRecording: state.status === 'RUNNING',
+ startSession,
+ stopSession,
+ cancelSession,
+ error: state.error,
+ };
+};
diff --git a/src/hooks/useLiveTranscription.ts b/src/hooks/useLiveTranscription.ts
new file mode 100644
index 0000000..6db6b66
--- /dev/null
+++ b/src/hooks/useLiveTranscription.ts
@@ -0,0 +1,166 @@
+/**
+ * Custom hook for managing live session state
+ */
+
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { NegotiationMode, LiveSessionState, Session } from '../types/session';
+import { SessionEngine } from '../services/SessionEngine';
+
+export interface UseSessionReturn {
+ sessionState: LiveSessionState;
+ isRecording: boolean;
+ startSession: (mode: NegotiationMode) => Promise;
+ stopSession: () => Promise;
+ cancelSession: () => Promise;
+ error: string | null;
+}
+
+/**
+ * Hook for managing live transcription and session
+ */
+export const useLiveTranscription = (): UseSessionReturn => {
+ const [sessionState, setSessionState] = useState({
+ isRecording: false,
+ startTime: 0,
+ duration: 0,
+ transcript: [],
+ detectedPatterns: [],
+ currentFocusScore: 100,
+ audioLevel: 0,
+ lastAutoSave: 0,
+ });
+ const [error, setError] = useState(null);
+ const sessionEngineRef = useRef(null);
+ const durationIntervalRef = useRef(null);
+
+ // Initialize session engine
+ useEffect(() => {
+ sessionEngineRef.current = new SessionEngine();
+
+ return () => {
+ // Cleanup on unmount
+ if (sessionEngineRef.current) {
+ sessionEngineRef.current.cleanup();
+ }
+ if (durationIntervalRef.current) {
+ clearInterval(durationIntervalRef.current);
+ }
+ };
+ }, []);
+
+ // Start duration timer
+ const startDurationTimer = useCallback(() => {
+ durationIntervalRef.current = setInterval(() => {
+ if (sessionEngineRef.current) {
+ sessionEngineRef.current.updateDuration();
+ }
+ }, 1000); // Update every second
+ }, []);
+
+ // Stop duration timer
+ const stopDurationTimer = useCallback(() => {
+ if (durationIntervalRef.current) {
+ clearInterval(durationIntervalRef.current);
+ durationIntervalRef.current = null;
+ }
+ }, []);
+
+ // Start session
+ const startSession = useCallback(
+ async (mode: NegotiationMode): Promise => {
+ if (!sessionEngineRef.current) {
+ setError('Session engine not initialized');
+ return false;
+ }
+
+ setError(null);
+
+ try {
+ const started = await sessionEngineRef.current.startSession(mode, (state) => {
+ setSessionState(state);
+ });
+
+ if (started) {
+ startDurationTimer();
+ return true;
+ } else {
+ setError('Failed to start recording. Please check microphone permissions.');
+ return false;
+ }
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
+ setError(`Failed to start session: ${errorMessage}`);
+ return false;
+ }
+ },
+ [startDurationTimer]
+ );
+
+ // Stop session
+ const stopSession = useCallback(async (): Promise => {
+ if (!sessionEngineRef.current) {
+ return null;
+ }
+
+ stopDurationTimer();
+
+ try {
+ const session = await sessionEngineRef.current.stopSession();
+
+ // Reset state
+ setSessionState({
+ isRecording: false,
+ startTime: 0,
+ duration: 0,
+ transcript: [],
+ detectedPatterns: [],
+ currentFocusScore: 100,
+ audioLevel: 0,
+ lastAutoSave: 0,
+ });
+
+ return session;
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
+ setError(`Failed to stop session: ${errorMessage}`);
+ return null;
+ }
+ }, [stopDurationTimer]);
+
+ // Cancel session
+ const cancelSession = useCallback(async (): Promise => {
+ if (!sessionEngineRef.current) {
+ return;
+ }
+
+ stopDurationTimer();
+
+ try {
+ await sessionEngineRef.current.cancelSession();
+
+ // Reset state
+ setSessionState({
+ isRecording: false,
+ startTime: 0,
+ duration: 0,
+ transcript: [],
+ detectedPatterns: [],
+ currentFocusScore: 100,
+ audioLevel: 0,
+ lastAutoSave: 0,
+ });
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
+ setError(`Failed to cancel session: ${errorMessage}`);
+ }
+ }, [stopDurationTimer]);
+
+ return {
+ sessionState,
+ isRecording: sessionState.isRecording,
+ startSession,
+ stopSession,
+ cancelSession,
+ error,
+ };
+};
diff --git a/src/hooks/useSessionAnalyzer.ts b/src/hooks/useSessionAnalyzer.ts
new file mode 100644
index 0000000..18c4f94
--- /dev/null
+++ b/src/hooks/useSessionAnalyzer.ts
@@ -0,0 +1,119 @@
+/**
+ * Custom hook for session analysis and statistics
+ */
+
+import { useState, useEffect, useCallback } from 'react';
+import { Session, SessionStats } from '../types/session';
+import { LocalStorageService } from '../services/LocalStorageService';
+
+export interface UseSessionAnalyzerReturn {
+ sessions: Session[];
+ stats: SessionStats | null;
+ isLoading: boolean;
+ error: string | null;
+ refreshSessions: () => Promise;
+ getSession: (sessionId: string) => Promise;
+ deleteSession: (sessionId: string) => Promise;
+ deleteAllSessions: () => Promise;
+}
+
+/**
+ * Hook for analyzing and managing sessions
+ */
+export const useSessionAnalyzer = (): UseSessionAnalyzerReturn => {
+ const [sessions, setSessions] = useState([]);
+ const [stats, setStats] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Load sessions and stats
+ const loadData = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const [loadedSessions, loadedStats] = await Promise.all([
+ LocalStorageService.getAllSessions(),
+ LocalStorageService.getStats(),
+ ]);
+
+ setSessions(loadedSessions);
+ setStats(loadedStats);
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
+ setError(`Failed to load sessions: ${errorMessage}`);
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ // Initial load
+ useEffect(() => {
+ loadData();
+ }, [loadData]);
+
+ // Refresh sessions
+ const refreshSessions = useCallback(async () => {
+ await loadData();
+ }, [loadData]);
+
+ // Get single session
+ const getSession = useCallback(async (sessionId: string): Promise => {
+ try {
+ return await LocalStorageService.getSession(sessionId);
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
+ setError(`Failed to get session: ${errorMessage}`);
+ return null;
+ }
+ }, []);
+
+ // Delete session
+ const deleteSession = useCallback(async (sessionId: string): Promise => {
+ try {
+ await LocalStorageService.deleteSession(sessionId);
+
+ // Update local state
+ setSessions((prev) => prev.filter((s) => s.id !== sessionId));
+
+ // Recalculate stats
+ const newStats = await LocalStorageService.calculateStats();
+ setStats(newStats);
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
+ setError(`Failed to delete session: ${errorMessage}`);
+ }
+ }, []);
+
+ // Delete all sessions
+ const deleteAllSessions = useCallback(async (): Promise => {
+ try {
+ await LocalStorageService.deleteAllSessions();
+
+ // Update local state
+ setSessions([]);
+ setStats({
+ totalSessions: 0,
+ avgFocusScore: 0,
+ avgDuration: 0,
+ mostCommonPattern: null,
+ totalPatterns: 0,
+ lastSessionDate: null,
+ });
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
+ setError(`Failed to delete all sessions: ${errorMessage}`);
+ }
+ }, []);
+
+ return {
+ sessions,
+ stats,
+ isLoading,
+ error,
+ refreshSessions,
+ getSession,
+ deleteSession,
+ deleteAllSessions,
+ };
+};
diff --git a/src/navigation/types.ts b/src/navigation/types.ts
index 8f849dd..12c7b42 100644
--- a/src/navigation/types.ts
+++ b/src/navigation/types.ts
@@ -1,8 +1,26 @@
+import { NegotiationMode } from '../types/session';
+
export type RootStackParamList = {
+ Disclaimer: undefined;
Home: undefined;
- Chat: undefined;
- ToolCalling: undefined;
- SpeechToText: undefined;
- TextToSpeech: undefined;
- VoicePipeline: undefined;
+ PreSessionForm: {
+ mode: NegotiationMode;
+ };
+ PreSessionStrategy: {
+ mode: NegotiationMode;
+ inputs: import('../types/session').PreSessionInputs;
+ analysis: import('../types/session').StrategicAnalysis;
+ };
+ LiveSession: {
+ mode: NegotiationMode;
+ preSessionInputs?: import('../types/session').PreSessionInputs;
+ strategicAnalysis?: import('../types/session').StrategicAnalysis;
+ };
+ OutcomeReplay: {
+ sessionId?: string; // Optional: If empty, load latest
+ };
+ Insights: {
+ sessionId: string;
+ };
+ Settings: undefined;
};
diff --git a/src/screens/ChatScreen.tsx b/src/screens/ChatScreen.tsx
deleted file mode 100644
index 8bdc707..0000000
--- a/src/screens/ChatScreen.tsx
+++ /dev/null
@@ -1,330 +0,0 @@
-import React, { useState, useRef, useEffect } from 'react';
-import {
- View,
- Text,
- TextInput,
- TouchableOpacity,
- FlatList,
- StyleSheet,
- KeyboardAvoidingView,
- Platform,
-} from 'react-native';
-import LinearGradient from 'react-native-linear-gradient';
-import { RunAnywhere } from '@runanywhere/core';
-import { AppColors } from '../theme';
-import { useModelService } from '../services/ModelService';
-import { ChatMessageBubble, ChatMessage, ModelLoaderWidget } from '../components';
-
-export const ChatScreen: React.FC = () => {
- const modelService = useModelService();
- const [messages, setMessages] = useState([]);
- const [inputText, setInputText] = useState('');
- const [isGenerating, setIsGenerating] = useState(false);
- const [currentResponse, setCurrentResponse] = useState('');
- const flatListRef = useRef(null);
- const streamCancelRef = useRef<(() => void) | null>(null);
- const responseRef = useRef(''); // Track response for closure
-
- useEffect(() => {
- // Scroll to bottom when messages change
- if (messages.length > 0) {
- setTimeout(() => {
- flatListRef.current?.scrollToEnd({ animated: true });
- }, 100);
- }
- }, [messages, currentResponse]);
-
- const handleSend = async () => {
- const text = inputText.trim();
- if (!text || isGenerating) return;
-
- // Add user message
- const userMessage: ChatMessage = {
- text,
- isUser: true,
- timestamp: new Date(),
- };
- setMessages(prev => [...prev, userMessage]);
- setInputText('');
- setIsGenerating(true);
- setCurrentResponse('');
-
- try {
- // Per docs: https://docs.runanywhere.ai/react-native/quick-start#6-stream-responses
- const streamResult = await RunAnywhere.generateStream(text, {
- maxTokens: 256,
- temperature: 0.8,
- });
-
- streamCancelRef.current = streamResult.cancel;
- responseRef.current = '';
-
- // Stream tokens as they arrive
- for await (const token of streamResult.stream) {
- responseRef.current += token;
- setCurrentResponse(responseRef.current);
- }
-
- // Get final metrics
- const finalResult = await streamResult.result;
-
- // Add assistant message (use ref to get final text due to closure)
- const assistantMessage: ChatMessage = {
- text: responseRef.current,
- isUser: false,
- timestamp: new Date(),
- tokensPerSecond: finalResult.performanceMetrics?.tokensPerSecond,
- totalTokens: finalResult.performanceMetrics?.totalTokens,
- };
- setMessages(prev => [...prev, assistantMessage]);
- setCurrentResponse('');
- responseRef.current = '';
- setIsGenerating(false);
- } catch (error) {
- const errorMessage: ChatMessage = {
- text: `Error: ${error}`,
- isUser: false,
- timestamp: new Date(),
- isError: true,
- };
- setMessages(prev => [...prev, errorMessage]);
- setCurrentResponse('');
- setIsGenerating(false);
- }
- };
-
- const handleStop = () => {
- if (streamCancelRef.current) {
- streamCancelRef.current();
- if (responseRef.current) {
- const message: ChatMessage = {
- text: responseRef.current,
- isUser: false,
- timestamp: new Date(),
- wasCancelled: true,
- };
- setMessages(prev => [...prev, message]);
- }
- setCurrentResponse('');
- responseRef.current = '';
- setIsGenerating(false);
- }
- };
-
- const handleClearChat = () => {
- setMessages([]);
- };
-
- const renderSuggestionChip = (text: string) => (
- {
- setInputText(text);
- handleSend();
- }}
- >
- {text}
-
- );
-
- if (!modelService.isLLMLoaded) {
- return (
-
- );
- }
-
- return (
-
- {messages.length === 0 ? (
-
-
- đŦ
-
- Start a Conversation
-
- Ask anything! The AI runs entirely on your device.
-
-
- {renderSuggestionChip('Tell me a joke')}
- {renderSuggestionChip('What is AI?')}
- {renderSuggestionChip('Write a haiku')}
-
-
- ) : (
- (
-
- )}
- keyExtractor={(_, index) => index.toString()}
- contentContainerStyle={styles.messageList}
- showsVerticalScrollIndicator={false}
- />
- )}
-
- {/* Input Area */}
-
-
-
- {isGenerating ? (
-
-
- âš
-
-
- ) : (
-
-
- đ¤
-
-
- )}
-
-
-
- );
-};
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: AppColors.primaryDark,
- },
- messageList: {
- padding: 16,
- },
- emptyState: {
- flex: 1,
- justifyContent: 'center',
- alignItems: 'center',
- padding: 32,
- },
- emptyIconContainer: {
- width: 100,
- height: 100,
- backgroundColor: AppColors.accentCyan + '20',
- borderRadius: 50,
- justifyContent: 'center',
- alignItems: 'center',
- marginBottom: 24,
- },
- emptyIcon: {
- fontSize: 48,
- },
- emptyTitle: {
- fontSize: 24,
- fontWeight: '700',
- color: AppColors.textPrimary,
- marginBottom: 12,
- },
- emptySubtitle: {
- fontSize: 14,
- color: AppColors.textSecondary,
- textAlign: 'center',
- marginBottom: 32,
- },
- suggestionsContainer: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- justifyContent: 'center',
- gap: 8,
- },
- suggestionChip: {
- paddingHorizontal: 16,
- paddingVertical: 8,
- backgroundColor: AppColors.surfaceCard,
- borderRadius: 20,
- borderWidth: 1,
- borderColor: AppColors.accentCyan + '40',
- },
- suggestionText: {
- fontSize: 12,
- color: AppColors.textPrimary,
- },
- inputContainer: {
- padding: 16,
- backgroundColor: AppColors.surfaceCard + 'CC',
- borderTopWidth: 1,
- borderTopColor: AppColors.textMuted + '1A',
- },
- inputWrapper: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 12,
- },
- input: {
- flex: 1,
- backgroundColor: AppColors.primaryMid,
- borderRadius: 24,
- paddingHorizontal: 20,
- paddingVertical: 12,
- fontSize: 15,
- color: AppColors.textPrimary,
- maxHeight: 100,
- },
- sendButton: {
- width: 48,
- height: 48,
- borderRadius: 24,
- justifyContent: 'center',
- alignItems: 'center',
- elevation: 4,
- shadowColor: AppColors.accentCyan,
- shadowOffset: { width: 0, height: 4 },
- shadowOpacity: 0.3,
- shadowRadius: 8,
- },
- sendIcon: {
- fontSize: 20,
- },
- stopButton: {
- width: 48,
- height: 48,
- borderRadius: 24,
- backgroundColor: AppColors.error + '33',
- justifyContent: 'center',
- alignItems: 'center',
- },
- stopIcon: {
- width: 48,
- height: 48,
- justifyContent: 'center',
- alignItems: 'center',
- },
- stopIconText: {
- fontSize: 20,
- color: AppColors.error,
- },
-});
diff --git a/src/screens/DisclaimerScreen.tsx b/src/screens/DisclaimerScreen.tsx
new file mode 100644
index 0000000..c673480
--- /dev/null
+++ b/src/screens/DisclaimerScreen.tsx
@@ -0,0 +1,166 @@
+import React, { useEffect, useState } from 'react';
+import { View, Text, StyleSheet, TouchableOpacity, SafeAreaView } from 'react-native';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { AppColors } from '../theme';
+import { StackNavigationProp } from '@react-navigation/stack';
+import { RootStackParamList } from '../navigation/types';
+
+type DisclaimerScreenNavigationProp = StackNavigationProp;
+
+interface DisclaimerScreenProps {
+ navigation: DisclaimerScreenNavigationProp;
+}
+
+const DISCLAIMER_KEY = '@latent_disclaimer_accepted';
+
+export const DisclaimerScreen: React.FC = ({ navigation }) => {
+ const [checking, setChecking] = useState(true);
+
+ useEffect(() => {
+ checkDisclaimer();
+ }, []);
+
+ const checkDisclaimer = async () => {
+ try {
+ const isAccepted = await AsyncStorage.getItem(DISCLAIMER_KEY);
+ if (isAccepted === 'true') {
+ navigation.replace('Home');
+ } else {
+ setChecking(false);
+ }
+ } catch (e) {
+ setChecking(false);
+ }
+ };
+
+ const handleAccept = async () => {
+ try {
+ await AsyncStorage.setItem(DISCLAIMER_KEY, 'true');
+ navigation.replace('Home');
+ } catch (e) {
+ console.error('Failed to save disclaimer acceptance', e);
+ }
+ };
+
+ if (checking) {
+ return (
+
+ Loading...
+
+ );
+ }
+
+ return (
+
+
+
+ â ī¸
+
+
+ Important Notice
+
+
+
+ âĸ This app records and analyzes conversations locally on this device.
+
+
+
+ âĸ You must comply with all local, state, and federal recording laws (e.g., two-party consent laws) in your jurisdiction.
+
+
+
+ âĸ Live mode is intended strictly for permitted environments such as authorized business negotiations, mock interviews, or startup pitches.
+
+
+
+ âĸ This software is not intended for covert or prohibited usage. You are solely responsible for how you apply this technology.
+
+
+
+
+ I Understand & Agree
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#0A0E1A', // Dark professional theme
+ },
+ loadingContainer: {
+ flex: 1,
+ backgroundColor: '#0A0E1A',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ loadingText: {
+ color: '#8B949E',
+ fontSize: 16,
+ },
+ content: {
+ flex: 1,
+ padding: 24,
+ justifyContent: 'center',
+ },
+ iconContainer: {
+ alignItems: 'center',
+ marginBottom: 24,
+ },
+ icon: {
+ fontSize: 64,
+ },
+ title: {
+ fontSize: 28,
+ fontWeight: '800',
+ color: '#FFFFFF',
+ marginBottom: 32,
+ textAlign: 'center',
+ letterSpacing: 0.5,
+ },
+ card: {
+ backgroundColor: '#161B22',
+ borderRadius: 16,
+ padding: 24,
+ borderWidth: 1,
+ borderColor: '#30363D',
+ marginBottom: 40,
+ },
+ bulletPoint: {
+ fontSize: 16,
+ color: '#E6EDF3',
+ lineHeight: 24,
+ marginBottom: 20,
+ },
+ bullet: {
+ color: AppColors.error,
+ fontWeight: '900',
+ },
+ bold: {
+ fontWeight: '700',
+ color: '#FFFFFF',
+ },
+ acceptButton: {
+ backgroundColor: AppColors.accentViolet,
+ borderRadius: 12,
+ paddingVertical: 18,
+ alignItems: 'center',
+ shadowColor: AppColors.accentViolet,
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.3,
+ shadowRadius: 8,
+ elevation: 5,
+ },
+ acceptButtonText: {
+ color: '#FFFFFF',
+ fontSize: 18,
+ fontWeight: '700',
+ letterSpacing: 0.5,
+ },
+});
diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx
index e734d68..d40544e 100644
--- a/src/screens/HomeScreen.tsx
+++ b/src/screens/HomeScreen.tsx
@@ -1,134 +1,373 @@
-import React from 'react';
+import React, { useEffect, useState } from 'react';
import {
View,
Text,
ScrollView,
StyleSheet,
+ TouchableOpacity,
+ RefreshControl,
StatusBar,
+ Alert,
+ Modal,
+ Dimensions,
} from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import { StackNavigationProp } from '@react-navigation/stack';
import { AppColors } from '../theme';
-import { FeatureCard } from '../components';
import { RootStackParamList } from '../navigation/types';
+import { useSessionAnalyzer } from '../hooks/useSessionAnalyzer';
+import { SessionSummaryCard } from '../components/SessionSummaryCard';
+import { getAllModes } from '../ai/patternLibrary';
+import { NegotiationMode, ModeConfig } from '../types/session';
+
+const { width: SCREEN_WIDTH } = Dimensions.get('window');
type HomeScreenProps = {
navigation: StackNavigationProp;
};
export const HomeScreen: React.FC = ({ navigation }) => {
+ const { sessions, stats, isLoading, refreshSessions } = useSessionAnalyzer();
+ const [showModeSelector, setShowModeSelector] = useState(false);
+ const allModes = getAllModes();
+
+ useEffect(() => {
+ const unsubscribe = navigation.addListener('focus', () => {
+ refreshSessions();
+ });
+ return unsubscribe;
+ }, [navigation, refreshSessions]);
+
+ const handleStartSession = (mode: NegotiationMode) => {
+ setShowModeSelector(false);
+ navigation.navigate('PreSessionForm', { mode });
+ };
+
+ const handleSessionPress = (sessionId: string) => {
+ navigation.navigate('Insights', { sessionId });
+ };
+
+ const formatDuration = (ms: number): string => {
+ const minutes = Math.floor(ms / 60000);
+ return `${minutes}min`;
+ };
+
+ const getGreeting = (): string => {
+ const hour = new Date().getHours();
+ if (hour < 12) return 'Good Morning';
+ if (hour < 17) return 'Good Afternoon';
+ return 'Good Evening';
+ };
+
return (
-
+
+
+ }
>
{/* Header */}
-
+
- âĄ
+ L
+
+ {getGreeting()},
+ Latent Strategist
+
-
- RunAnywhere
- React Native SDK Starter
-
+ navigation.navigate('Settings')}
+ >
+ đ
+
- {/* Privacy Banner */}
-
- đ
-
- Privacy-First On-Device AI
-
- All AI processing happens locally on your device. No data ever leaves your phone.
-
-
+ {/* Balance Card */}
+
+ TOTAL SESSIONS
+
+ {stats ? stats.totalSessions : 0}
+
+ {stats && stats.totalSessions > 0 && (
+
+
+ đ {Math.round(stats.avgFocusScore)}% avg focus
+
+
+ )}
+ {!stats || stats.totalSessions === 0 ? (
+
+ ⨠Ready to start
+
+ ) : null}
+
+
+ {/* Action Buttons */}
+
+ {/* Live Tactical Mode */}
+ setShowModeSelector(true)}
+ activeOpacity={0.8}
+ >
+
+ đ¤
+
+
+ Live Tactical Mode
+ Real-time negotiation intelligence
+
+
+
+ {/* Strategic Outcome Replay */}
+ {
+ if (sessions.length > 0) {
+ navigation.navigate('OutcomeReplay', { sessionId: sessions[0].id });
+ } else {
+ Alert.alert('No Sessions', 'Complete a session first to view replays.');
+ }
+ }}
+ activeOpacity={0.8}
+ >
+
+ đ§
+
+
+ Strategic Outcome Replayâĸ
+ Counterfactual behavior modeling
+
+
+
+ {/* Practice Simulation */}
+ Alert.alert('Coming Soon', 'Practice Simulation Engine is under construction.')}
+ activeOpacity={0.8}
+ >
+
+ âī¸
+
+
+ Practice Simulation
+ Mock scenarios against AI
+
+
- {/* Feature Cards Grid */}
-
-
- navigation.navigate('Chat')}
- />
- navigation.navigate('ToolCalling')}
- />
-
-
- navigation.navigate('SpeechToText')}
- />
- navigation.navigate('TextToSpeech')}
- />
-
-
- navigation.navigate('VoicePipeline')}
- />
-
+ {/* Recent Sessions */}
+
+
+ Recent Sessions
+ {sessions.length > 0 && (
+
+ See All
+
+ )}
+
+ {sessions.length === 0 ? (
+
+
+ đ
+
+ No sessions yet
+
+ Start your first session to see insights here
+
+ setShowModeSelector(true)}
+ >
+
+ Start Session
+
+
+
+ ) : (
+ sessions.map((session) => (
+ handleSessionPress(session.id)}
+ />
+ ))
+ )}
- {/* Model Info Section */}
-
-
- đ¤
- LLM
-
- SmolLM2 360M
+ {/* Quick Stats */}
+ {stats && stats.totalSessions > 0 && (
+
+ Quick Stats
+
+
+
+ đ¯
+
+
+ {Math.round(stats.avgFocusScore)}%
+
+ Avg Focus
+
+
+
+ đ
+
+
+ {stats.totalPatterns}
+
+ Patterns
+
+
+
+ âąī¸
+
+
+ {formatDuration(stats.avgDuration)}
+
+ Avg Duration
+
+
+
+ đ
+
+ 100%
+ Private
+
+
-
- đ¤
- STT
-
- Whisper Tiny
-
-
- đ
- TTS
-
- Piper TTS
-
-
+ )}
+
+ {/* Floating Action Button */}
+ setShowModeSelector(true)}
+ activeOpacity={0.85}
+ >
+
+ đ¤
+
+
+
+ {/* Bottom Navigation Bar */}
+
+
+ đ
+ Home
+
+ {
+ if (sessions.length > 0) {
+ handleSessionPress(sessions[0].id);
+ }
+ }}
+ >
+ đ
+ Insights
+
+
+
+ đ
+ History
+
+ navigation.navigate('Settings')}
+ >
+ đ¤
+ Profile
+
+
+
+ {/* Mode Selector Modal */}
+ setShowModeSelector(false)}
+ >
+
+
+
+ Select Live Mode Category
+ Live mode is recommended for permitted business and negotiation environments. Avoid usage where recording is restricted.
+
+
+ {allModes.map((modeConfig: ModeConfig) => (
+ handleStartSession(modeConfig.mode)}
+ activeOpacity={0.7}
+ >
+
+ {modeConfig.icon}
+
+
+ {modeConfig.displayName}
+ {modeConfig.description}
+
+ âē
+
+ ))}
+
+
+ setShowModeSelector(false)}
+ >
+ Cancel
+
+
+
+
);
};
@@ -136,7 +375,7 @@ export const HomeScreen: React.FC = ({ navigation }) => {
const styles = StyleSheet.create({
container: {
flex: 1,
- backgroundColor: AppColors.primaryDark,
+ backgroundColor: AppColors.primaryLight,
},
gradient: {
flex: 1,
@@ -145,105 +384,417 @@ const styles = StyleSheet.create({
flex: 1,
},
scrollContent: {
- padding: 24,
- paddingTop: 60,
+ paddingHorizontal: 24,
+ paddingTop: 56,
+ paddingBottom: 100,
},
+
+ // Header
header: {
flexDirection: 'row',
+ justifyContent: 'space-between',
alignItems: 'center',
- marginBottom: 40,
+ marginBottom: 28,
},
- logoContainer: {
- marginRight: 16,
+ headerLeft: {
+ flexDirection: 'row',
+ alignItems: 'center',
},
- logoGradient: {
- width: 60,
- height: 60,
- borderRadius: 16,
+ avatar: {
+ width: 48,
+ height: 48,
+ borderRadius: 24,
justifyContent: 'center',
alignItems: 'center',
- elevation: 8,
- shadowColor: AppColors.accentCyan,
- shadowOffset: { width: 0, height: 4 },
- shadowOpacity: 0.4,
- shadowRadius: 12,
+ marginRight: 14,
},
- logoIcon: {
- fontSize: 32,
+ avatarText: {
+ fontSize: 20,
+ fontWeight: '700',
+ color: '#FFFFFF',
},
- headerText: {
- flex: 1,
+ headerTextContainer: {
+ justifyContent: 'center',
},
- title: {
- fontSize: 28,
+ greeting: {
+ fontSize: 14,
+ color: '#6B6B80',
+ fontWeight: '400',
+ marginBottom: 4,
+ },
+ userName: {
+ fontSize: 18,
fontWeight: '700',
- color: AppColors.textPrimary,
- letterSpacing: -0.5,
+ letterSpacing: 0.5,
+ color: '#1E1E2C',
},
- subtitle: {
- fontSize: 14,
- fontWeight: '500',
- color: AppColors.accentCyan,
- marginTop: 2,
+ notificationButton: {
+ width: 44,
+ height: 44,
+ borderRadius: 22,
+ backgroundColor: 'rgba(255, 255, 255, 0.7)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ borderWidth: 1,
+ borderColor: 'rgba(123, 97, 255, 0.08)',
+ },
+ notificationIcon: {
+ fontSize: 20,
+ },
+
+ // Balance Card
+ balanceCard: {
+ borderRadius: 24,
+ padding: 28,
+ marginBottom: 28,
+ alignItems: 'center',
+ elevation: 12,
+ shadowColor: '#7B61FF',
+ shadowOffset: { width: 0, height: 10 },
+ shadowOpacity: 0.35,
+ shadowRadius: 20,
+ },
+ balanceLabel: {
+ fontSize: 13,
+ fontWeight: '600',
+ color: 'rgba(255, 255, 255, 0.7)',
+ letterSpacing: 2,
+ marginBottom: 12,
+ },
+ balanceAmount: {
+ fontSize: 48,
+ fontWeight: '700',
+ color: '#FFFFFF',
+ letterSpacing: -1,
+ marginBottom: 14,
+ },
+ balanceBadge: {
+ backgroundColor: 'rgba(255, 255, 255, 0.2)',
+ paddingHorizontal: 16,
+ paddingVertical: 6,
+ borderRadius: 20,
+ },
+ balanceBadgeText: {
+ fontSize: 13,
+ fontWeight: '600',
+ color: '#FFFFFF',
+ },
+
+ actionColumn: {
+ flexDirection: 'column',
+ gap: 16,
+ marginBottom: 36,
},
- privacyBanner: {
+ actionItemLarge: {
flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: '#FFFFFF',
padding: 20,
- backgroundColor: AppColors.surfaceCard + 'CC',
- borderRadius: 16,
- borderWidth: 1,
- borderColor: AppColors.accentCyan + '33',
- marginBottom: 32,
+ borderRadius: 24,
+ elevation: 4,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.05,
+ shadowRadius: 10,
+ },
+ actionCircleLarge: {
+ width: 64,
+ height: 64,
+ borderRadius: 32,
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginRight: 20,
},
- privacyIcon: {
+ actionIconLarge: {
fontSize: 28,
- marginRight: 16,
},
- privacyText: {
+ actionTextContent: {
flex: 1,
},
- privacyTitle: {
- fontSize: 16,
- fontWeight: '600',
- color: AppColors.textPrimary,
+ actionLabelLarge: {
+ fontSize: 18,
+ fontWeight: '800',
+ color: '#1A1A2E',
marginBottom: 4,
},
- privacySubtitle: {
- fontSize: 12,
- color: AppColors.textSecondary,
- lineHeight: 18,
+ actionSubLabelLarge: {
+ fontSize: 14,
+ color: '#8B949E',
},
- gridContainer: {
- marginBottom: 24,
+
+ // Section
+ sessionsSection: {
+ marginBottom: 28,
},
- row: {
+ sectionHeader: {
flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
marginBottom: 16,
- gap: 0,
},
- infoSection: {
- padding: 20,
- backgroundColor: AppColors.surfaceCard + '80',
+ sectionTitle: {
+ fontSize: 18,
+ fontWeight: '700',
+ color: AppColors.textPrimary,
+ },
+ seeAllText: {
+ fontSize: 14,
+ color: AppColors.accentPrimary,
+ fontWeight: '600',
+ },
+
+ // Empty State
+ emptyState: {
+ alignItems: 'center',
+ backgroundColor: '#FFFFFF',
+ borderRadius: 24,
+ padding: 36,
+ elevation: 2,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.06,
+ shadowRadius: 8,
+ },
+ emptyIconCircle: {
+ width: 72,
+ height: 72,
+ borderRadius: 36,
+ backgroundColor: '#EDE9FE',
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginBottom: 16,
+ },
+ emptyIcon: {
+ fontSize: 32,
+ },
+ emptyText: {
+ fontSize: 18,
+ fontWeight: '700',
+ color: AppColors.textPrimary,
+ marginBottom: 6,
+ },
+ emptySubtext: {
+ fontSize: 14,
+ color: AppColors.textSecondary,
+ textAlign: 'center',
+ lineHeight: 20,
+ marginBottom: 20,
+ },
+ emptyButton: {
borderRadius: 16,
- borderWidth: 1,
- borderColor: AppColors.textMuted + '1A',
+ overflow: 'hidden',
+ },
+ emptyButtonGradient: {
+ paddingHorizontal: 32,
+ paddingVertical: 14,
+ borderRadius: 16,
+ },
+ emptyButtonText: {
+ fontSize: 15,
+ fontWeight: '700',
+ color: '#FFFFFF',
+ },
+
+ // Quick Stats
+ quickStatsSection: {
+ marginBottom: 20,
},
- infoRow: {
+ quickStatsGrid: {
flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: 12,
+ marginTop: 14,
+ },
+ quickStatCard: {
+ width: (SCREEN_WIDTH - 60) / 2,
+ backgroundColor: '#FFFFFF',
+ borderRadius: 20,
+ padding: 18,
+ alignItems: 'center',
+ elevation: 2,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.05,
+ shadowRadius: 8,
+ },
+ quickStatIcon: {
+ width: 48,
+ height: 48,
+ borderRadius: 16,
+ justifyContent: 'center',
alignItems: 'center',
+ marginBottom: 12,
+ },
+ quickStatEmoji: {
+ fontSize: 22,
+ },
+ quickStatValue: {
+ fontSize: 22,
+ fontWeight: '700',
+ color: AppColors.textPrimary,
+ marginBottom: 4,
+ },
+ quickStatLabel: {
+ fontSize: 12,
+ color: AppColors.textSecondary,
+ fontWeight: '500',
+ },
+
+ // FAB
+ fab: {
+ position: 'absolute',
+ bottom: 72,
+ alignSelf: 'center',
+ zIndex: 10,
+ elevation: 12,
+ shadowColor: '#7B61FF',
+ shadowOffset: { width: 0, height: 6 },
+ shadowOpacity: 0.4,
+ shadowRadius: 12,
+ },
+ fabGradient: {
+ width: 60,
+ height: 60,
+ borderRadius: 30,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ fabIcon: {
+ fontSize: 26,
+ },
+
+ // Bottom Nav
+ bottomNav: {
+ flexDirection: 'row',
+ backgroundColor: '#FFFFFF',
paddingVertical: 8,
+ paddingHorizontal: 16,
+ paddingBottom: 20,
+ borderTopLeftRadius: 24,
+ borderTopRightRadius: 24,
+ alignItems: 'center',
+ justifyContent: 'space-around',
+ elevation: 10,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: -4 },
+ shadowOpacity: 0.08,
+ shadowRadius: 12,
},
- infoIcon: {
- fontSize: 20,
- marginRight: 12,
+ navItem: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ flex: 1,
+ paddingVertical: 6,
+ },
+ navItemSpacer: {
+ width: 60,
+ },
+ navIcon: {
+ fontSize: 22,
+ marginBottom: 4,
+ opacity: 0.4,
+ },
+ navIconActive: {
+ fontSize: 22,
+ marginBottom: 4,
+ },
+ navLabel: {
+ fontSize: 10,
+ color: AppColors.textMuted,
+ fontWeight: '500',
+ },
+ navLabelActive: {
+ fontSize: 10,
+ color: AppColors.accentPrimary,
+ fontWeight: '700',
},
- infoLabel: {
+
+ // Modal
+ modalOverlay: {
+ flex: 1,
+ backgroundColor: 'rgba(0, 0, 0, 0.4)',
+ justifyContent: 'flex-end',
+ },
+ modalContent: {
+ backgroundColor: '#FFFFFF',
+ borderTopLeftRadius: 28,
+ borderTopRightRadius: 28,
+ padding: 24,
+ maxHeight: '80%',
+ },
+ modalHandle: {
+ width: 40,
+ height: 4,
+ borderRadius: 2,
+ backgroundColor: '#E5E7EB',
+ alignSelf: 'center',
+ marginBottom: 20,
+ },
+ modalTitle: {
+ fontSize: 24,
+ fontWeight: '700',
+ color: AppColors.textPrimary,
+ marginBottom: 6,
+ },
+ modalSubtitle: {
fontSize: 14,
color: AppColors.textSecondary,
+ marginBottom: 24,
},
- infoValue: {
- fontSize: 12,
- color: AppColors.accentCyan,
- fontWeight: '500',
+ modesScroll: {
+ maxHeight: 400,
+ },
+ modeCard: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: '#F8F5FF',
+ padding: 16,
+ borderRadius: 18,
+ marginBottom: 10,
+ borderWidth: 1,
+ borderColor: 'rgba(123, 97, 255, 0.08)',
+ },
+ modeIconCircle: {
+ width: 48,
+ height: 48,
+ borderRadius: 16,
+ backgroundColor: '#EDE9FE',
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginRight: 14,
+ },
+ modeIcon: {
+ fontSize: 22,
+ },
+ modeInfo: {
+ flex: 1,
+ },
+ modeTitle: {
+ fontSize: 16,
+ fontWeight: '600',
+ color: AppColors.textPrimary,
+ marginBottom: 3,
+ },
+ modeDescription: {
+ fontSize: 13,
+ color: AppColors.textSecondary,
+ lineHeight: 17,
+ },
+ modeArrow: {
+ fontSize: 22,
+ color: AppColors.textMuted,
+ marginLeft: 8,
+ },
+ cancelButton: {
+ marginTop: 14,
+ padding: 16,
+ backgroundColor: '#F3F4F6',
+ borderRadius: 16,
+ alignItems: 'center',
+ },
+ cancelButtonText: {
+ fontSize: 16,
+ fontWeight: '600',
+ color: AppColors.textSecondary,
},
});
diff --git a/src/screens/InsightsScreen.tsx b/src/screens/InsightsScreen.tsx
new file mode 100644
index 0000000..e2c239f
--- /dev/null
+++ b/src/screens/InsightsScreen.tsx
@@ -0,0 +1,252 @@
+import React, { useEffect, useState } from 'react';
+import {
+ View, Text, ScrollView, StyleSheet, TouchableOpacity, StatusBar, Alert,
+} from 'react-native';
+import LinearGradient from 'react-native-linear-gradient';
+import { StackNavigationProp } from '@react-navigation/stack';
+import { RouteProp } from '@react-navigation/native';
+import { AppColors } from '../theme';
+import { RootStackParamList } from '../navigation/types';
+import { Session } from '../types/session';
+import { useSessionAnalyzer } from '../hooks/useSessionAnalyzer';
+import { getModeConfig, getPatternDefinition } from '../ai/patternLibrary';
+import { CognitiveMeter } from '../components/CognitiveMeter';
+
+type InsightsScreenProps = {
+ navigation: StackNavigationProp;
+ route: RouteProp;
+};
+
+export const InsightsScreen: React.FC = ({ navigation, route }) => {
+ const { sessionId } = route.params;
+ const { getSession, deleteSession } = useSessionAnalyzer();
+ const [session, setSession] = useState(null);
+ const [selectedTab, setSelectedTab] = useState<'transcript' | 'patterns' | 'analysis'>('analysis');
+
+ useEffect(() => { loadSession(); }, [sessionId]);
+
+ const loadSession = async () => {
+ const s = await getSession(sessionId);
+ if (s) setSession(s);
+ else Alert.alert('Error', 'Session not found', [{ text: 'OK', onPress: () => navigation.goBack() }]);
+ };
+
+ const handleDelete = () => {
+ Alert.alert('Delete Session', 'This cannot be undone.', [
+ { text: 'Cancel', style: 'cancel' },
+ { text: 'Delete', style: 'destructive', onPress: async () => { await deleteSession(sessionId); navigation.goBack(); } },
+ ]);
+ };
+
+ if (!session) return (
+
+ Loading session...
+
+ );
+
+ const modeConfig = getModeConfig(session.mode);
+ const formatDate = (t: number) => new Date(t).toLocaleString('en-US', { month: 'long', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' });
+ const formatDuration = (ms: number) => { const m = Math.floor(ms / 60000); const s = Math.floor((ms % 60000) / 1000); return `${m}m ${s}s`; };
+
+ return (
+
+
+
+ {/* Header */}
+
+
+
+ {modeConfig.icon}
+ {modeConfig.displayName}
+
+
+ đī¸
+
+
+ {formatDate(session.timestamp)}
+
+
+ Duration
+ {formatDuration(session.duration)}
+
+
+ Patterns
+ {session.detectedPatterns.length}
+
+
+ Words
+ {session.cognitiveMetrics.totalWords}
+
+
+
+
+
+
+
+ {/* Tabs */}
+
+ {(['analysis', 'patterns', 'transcript'] as const).map((tab) => (
+ setSelectedTab(tab)}>
+
+ {tab.charAt(0).toUpperCase() + tab.slice(1)}
+
+
+ ))}
+
+
+ {/* Content */}
+
+ {selectedTab === 'analysis' && (
+
+ {session.summary.keyInsights.length > 0 && (
+
+ đĄ Key Insights
+ {session.summary.keyInsights.map((insight, i) => (
+
+
+ {insight}
+
+ ))}
+
+ )}
+ {session.summary.tacticalSuggestions.length > 0 && (
+
+ đ¯ Tactical Suggestions
+ {session.summary.tacticalSuggestions.map((s, i) => (
+
+ {i + 1}
+ {s}
+
+ ))}
+
+ )}
+ {session.summary.leverageMoments.length > 0 && (
+
+ â
Leverage Moments
+ {session.summary.leverageMoments.map((m, i) => (
+ {m}
+ ))}
+
+ )}
+ {session.summary.missedOpportunities.length > 0 && (
+
+ â ī¸ Missed Opportunities
+ {session.summary.missedOpportunities.map((o, i) => (
+ {o}
+ ))}
+
+ )}
+
+ đ§ Cognitive Metrics
+
+ {session.cognitiveMetrics.speechGaps}Speech Gaps
+ {session.cognitiveMetrics.fillerWords}Filler Words
+ {Math.round(session.cognitiveMetrics.avgSpeechRate)}WPM
+
+
+
+ )}
+ {selectedTab === 'patterns' && (
+
+ {session.detectedPatterns.map((p) => {
+ const d = getPatternDefinition(p.pattern);
+ return (
+
+
+ {d.displayName}
+ {Math.round(p.confidenceScore)}%
+
+ {d.description}
+ Suggestion:{p.suggestion}
+ {p.context && "{p.context}"}
+
+ );
+ })}
+ {session.detectedPatterns.length === 0 && No patterns detected}
+
+ )}
+ {selectedTab === 'transcript' && (
+
+ {session.transcript.map((c) => (
+
+ {new Date(c.timestamp).toLocaleTimeString()}
+ {c.text}
+
+ ))}
+ {session.transcript.length === 0 && No transcript available}
+
+ )}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: { flex: 1, backgroundColor: AppColors.primaryLight },
+ centered: { justifyContent: 'center', alignItems: 'center' },
+ loadingText: { fontSize: 16, color: AppColors.textSecondary },
+
+ header: { paddingTop: 50, paddingBottom: 24, paddingHorizontal: 20, borderBottomLeftRadius: 28, borderBottomRightRadius: 28 },
+ headerTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 },
+ modeTag: { flexDirection: 'row', alignItems: 'center', backgroundColor: 'rgba(255,255,255,0.2)', paddingVertical: 6, paddingHorizontal: 12, borderRadius: 10 },
+ modeIcon: { fontSize: 16, marginRight: 6 },
+ modeText: { fontSize: 13, fontWeight: '600', color: '#FFFFFF' },
+ deleteButton: { padding: 8, backgroundColor: 'rgba(255,255,255,0.15)', borderRadius: 10 },
+ deleteIcon: { fontSize: 20 },
+ dateText: { fontSize: 14, color: 'rgba(255,255,255,0.7)', marginBottom: 16 },
+
+ statsRow: { flexDirection: 'row', gap: 10, marginBottom: 20 },
+ headerStatCard: { flex: 1, alignItems: 'center', backgroundColor: 'rgba(255,255,255,0.15)', borderRadius: 16, padding: 14 },
+ headerStatLabel: { fontSize: 11, color: 'rgba(255,255,255,0.6)', marginBottom: 6, fontWeight: '500' },
+ headerStatValue: { fontSize: 18, fontWeight: '700', color: '#FFFFFF' },
+ focusSection: { alignItems: 'center' },
+
+ tabs: { flexDirection: 'row', backgroundColor: '#FFFFFF', paddingHorizontal: 4, paddingVertical: 4, marginHorizontal: 16, borderRadius: 16, marginTop: -6, marginBottom: 16, elevation: 3, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.06, shadowRadius: 6 },
+ tab: { flex: 1, paddingVertical: 10, alignItems: 'center', borderRadius: 12 },
+ tabActive: { backgroundColor: '#7B61FF' },
+ tabText: { fontSize: 14, fontWeight: '600', color: AppColors.textSecondary },
+ tabTextActive: { color: '#FFFFFF' },
+
+ content: { flex: 1 },
+ contentContainer: { padding: 16 },
+ section: { marginBottom: 24 },
+ sectionTitle: { fontSize: 18, fontWeight: '700', color: AppColors.textPrimary, marginBottom: 12 },
+
+ insightCard: { flexDirection: 'row', backgroundColor: '#FFFFFF', borderRadius: 16, marginBottom: 8, overflow: 'hidden', elevation: 2, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.04, shadowRadius: 4 },
+ insightAccent: { width: 4, backgroundColor: '#7B61FF' },
+ insightText: { flex: 1, fontSize: 14, color: AppColors.textPrimary, lineHeight: 20, padding: 14 },
+
+ suggestionCard: { flexDirection: 'row', backgroundColor: '#FFFFFF', padding: 14, borderRadius: 16, marginBottom: 8, alignItems: 'flex-start', elevation: 2, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.04, shadowRadius: 4 },
+ suggestionNum: { width: 28, height: 28, borderRadius: 10, backgroundColor: '#EDE9FE', justifyContent: 'center', alignItems: 'center', marginRight: 12 },
+ suggestionNumText: { fontSize: 14, fontWeight: '700', color: '#7B61FF' },
+ suggestionText: { flex: 1, fontSize: 14, color: AppColors.textPrimary, lineHeight: 20 },
+
+ momentCard: { backgroundColor: '#DCFCE7', padding: 14, borderRadius: 14, marginBottom: 8 },
+ momentText: { fontSize: 13, color: '#166534', lineHeight: 18 },
+ oppCard: { backgroundColor: '#FEF3C7', padding: 14, borderRadius: 14, marginBottom: 8 },
+ oppText: { fontSize: 13, color: '#92400E', lineHeight: 18 },
+
+ metricsGrid: { flexDirection: 'row', gap: 10 },
+ metricCard: { flex: 1, backgroundColor: '#FFFFFF', padding: 18, borderRadius: 18, alignItems: 'center', elevation: 2, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.04, shadowRadius: 4 },
+ metricValue: { fontSize: 24, fontWeight: '700', color: '#7B61FF', marginBottom: 6 },
+ metricLabel: { fontSize: 11, color: AppColors.textSecondary, textAlign: 'center', fontWeight: '500' },
+
+ patternCard: { backgroundColor: '#FFFFFF', padding: 16, borderRadius: 18, marginBottom: 12, elevation: 2, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.04, shadowRadius: 4 },
+ patternHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 },
+ patternName: { fontSize: 16, fontWeight: '700', color: AppColors.textPrimary, flex: 1 },
+ patternBadge: { backgroundColor: '#EDE9FE', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 10 },
+ patternBadgeText: { fontSize: 12, fontWeight: '700', color: '#7B61FF' },
+ patternDesc: { fontSize: 13, color: AppColors.textSecondary, marginBottom: 10, lineHeight: 18 },
+ patternSugg: { backgroundColor: '#F8F5FF', padding: 12, borderRadius: 12, marginBottom: 8 },
+ patternSuggLabel: { fontSize: 11, fontWeight: '600', color: AppColors.textMuted, marginBottom: 4 },
+ patternSuggText: { fontSize: 13, color: AppColors.textPrimary, lineHeight: 18 },
+ patternCtx: { padding: 12, backgroundColor: '#F9FAFB', borderRadius: 12, borderLeftWidth: 3, borderLeftColor: '#7B61FF' },
+ patternCtxText: { fontSize: 12, fontStyle: 'italic', color: AppColors.textSecondary, lineHeight: 16 },
+
+ tChunk: { marginBottom: 16, paddingBottom: 16, borderBottomWidth: 1, borderBottomColor: '#F3F4F6' },
+ tTime: { fontSize: 12, color: AppColors.textMuted, marginBottom: 6, fontWeight: '500' },
+ tText: { fontSize: 15, color: AppColors.textPrimary, lineHeight: 22 },
+
+ emptyState: { padding: 40, alignItems: 'center' },
+ emptyText: { fontSize: 16, color: AppColors.textSecondary },
+});
diff --git a/src/screens/LiveSessionScreen.tsx b/src/screens/LiveSessionScreen.tsx
new file mode 100644
index 0000000..abd9a6b
--- /dev/null
+++ b/src/screens/LiveSessionScreen.tsx
@@ -0,0 +1,283 @@
+import React, { useEffect, useState } from 'react';
+import { View, Text, StyleSheet, TouchableOpacity, Alert, StatusBar } from 'react-native';
+import LinearGradient from 'react-native-linear-gradient';
+import { StackNavigationProp } from '@react-navigation/stack';
+import { RouteProp } from '@react-navigation/native';
+import { AppColors } from '../theme';
+import { RootStackParamList } from '../navigation/types';
+import { LiveTranscript } from '../components/LiveTranscript';
+import { SuggestionCard } from '../components/SuggestionCard';
+import { CounterStrategyCard } from '../components/CounterStrategyCard';
+import { CognitiveMeter } from '../components/CognitiveMeter';
+import { getModeConfig } from '../ai/patternLibrary';
+import { LocalStorageService } from '../services/LocalStorageService';
+import { useModelService } from '../services/ModelService';
+import { useLiveSession } from '../hooks/useLiveSession';
+
+type LiveSessionScreenProps = {
+ navigation: StackNavigationProp;
+ route: RouteProp;
+};
+
+export const LiveSessionScreen: React.FC = ({ navigation, route }) => {
+ const { mode } = route.params;
+
+ // âââ Single source of truth: useReducer-based session hook âââ
+ // Replaces useLiveTranscription + useCounterStrategy
+ const { state, isRecording, startSession, stopSession, cancelSession, error } =
+ useLiveSession();
+
+ const [hasStarted, setHasStarted] = useState(false);
+ const [modelReady, setModelReady] = useState(false);
+ const [modelError, setModelError] = useState(false);
+ const { downloadAndLoadSTT, isSTTLoaded, isSTTLoading, isSTTDownloading, isSDKReady, sttDownloadProgress } = useModelService();
+
+ const modeConfig = getModeConfig(mode);
+
+ // âââ Suggestions and strategy from reducer state (no separate hooks) âââ
+ // The reducer handles all tactic detection, cooldown, and counter-strategy
+ // generation inside the TACTIC_DETECTED action. We just read the result.
+ const { activeStrategy, suggestions, detectedPatterns } = state;
+
+ // Kick off model download+load when SDK is ready
+ useEffect(() => {
+ if (!isSDKReady) {
+ console.log('[LiveSessionScreen] âŗ Waiting for SDK to initialize...');
+ return;
+ }
+
+ const initModel = async () => {
+ console.log('[LiveSessionScreen] đ Checking STT model status...');
+
+ // Check if debug mode is enabled
+ const settings = await LocalStorageService.getSettings();
+ const debugMode = settings.debugMode || false;
+
+ if (debugMode) {
+ console.log('[LiveSessionScreen] đ Debug mode enabled, skipping STT model check');
+ setModelReady(true);
+ return;
+ }
+
+ // If already loaded, we're done
+ if (isSTTLoaded) {
+ console.log('[LiveSessionScreen] â
STT model already loaded');
+ setModelReady(true);
+ return;
+ }
+
+ // Kick off download+load (fire and forget â we'll watch isSTTLoaded state)
+ console.log('[LiveSessionScreen] đ¤ Starting STT model download and load...');
+ downloadAndLoadSTT().catch((err) => {
+ console.error('[LiveSessionScreen] â downloadAndLoadSTT error:', err);
+ setModelError(true);
+ });
+ };
+
+ initModel();
+ }, [isSDKReady]); // Only run once when SDK becomes ready
+
+ // Watch for model loading completion from ModelService state
+ useEffect(() => {
+ if (isSTTLoaded && !modelReady) {
+ console.log('[LiveSessionScreen] â
STT model loaded (via ModelService state)');
+ setModelReady(true);
+ setModelError(false);
+ }
+ }, [isSTTLoaded, modelReady]);
+
+ // Watch for errors â show failure only when download/load finishes with error
+ // (not while still downloading)
+ useEffect(() => {
+ if (modelError && !isSTTDownloading && !isSTTLoading && !isSTTLoaded) {
+ Alert.alert(
+ 'Model Loading Failed',
+ 'The speech recognition model could not be loaded.\n\nOptions:\nâĸ Enable Debug Mode in Settings to test without audio\nâĸ Check your internet connection\nâĸ Try again',
+ [
+ { text: 'Go to Settings', onPress: () => navigation.navigate('Settings') },
+ {
+ text: 'Try Again',
+ onPress: () => {
+ setModelError(false);
+ downloadAndLoadSTT().catch(() => setModelError(true));
+ },
+ },
+ { text: 'Cancel', onPress: () => navigation.goBack() },
+ ]
+ );
+ }
+ }, [modelError, isSTTDownloading, isSTTLoading, isSTTLoaded, navigation, downloadAndLoadSTT]);
+
+ useEffect(() => {
+ if (error) Alert.alert('Error', error, [{ text: 'OK' }]);
+ }, [error]);
+
+ useEffect(() => {
+ if (!hasStarted && modelReady) {
+ setHasStarted(true);
+ startSession(mode).catch(() => navigation.goBack());
+ }
+ }, [hasStarted, modelReady, mode, startSession, navigation]);
+
+ const handleStop = () => {
+ Alert.alert('Stop Session', 'Your insights will be saved.', [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Stop', style: 'destructive', onPress: async () => {
+ const session = await stopSession();
+ session ? navigation.navigate('Insights', { sessionId: session.id }) : navigation.goBack();
+ },
+ },
+ ]);
+ };
+
+ const handleCancel = () => {
+ Alert.alert('Cancel Session', 'This session will not be saved.', [
+ { text: 'No', style: 'cancel' },
+ {
+ text: 'Yes, Cancel', style: 'destructive', onPress: async () => {
+ await cancelSession(); navigation.goBack();
+ },
+ },
+ ]);
+ };
+
+ const formatDuration = (ms: number): string => {
+ const totalSeconds = Math.floor(ms / 1000);
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = totalSeconds % 60;
+ if (hours > 0) return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
+ return `${minutes}:${seconds.toString().padStart(2, '0')}`;
+ };
+
+ if (!modelReady) {
+ const loadingTitle = !isSDKReady
+ ? 'đ§ Initializing AI Engine...'
+ : isSTTDownloading
+ ? `đĨ Downloading Model... ${Math.round(sttDownloadProgress)}%`
+ : isSTTLoading
+ ? 'đ¤ Loading AI Model...'
+ : 'đ¤ Preparing AI Model...';
+ const loadingText = !isSDKReady
+ ? 'Setting up on-device AI'
+ : isSTTDownloading
+ ? 'First-time setup (~75MB)'
+ : isSTTLoading
+ ? 'Almost ready...'
+ : 'Preparing speech recognition';
+ const loadingSubtext = !isSDKReady
+ ? 'This only takes a moment'
+ : isSTTDownloading
+ ? 'This only happens once'
+ : 'Please wait...';
+
+ return (
+
+
+
+ {loadingTitle}
+ {loadingText}
+ {loadingSubtext}
+
+
+ );
+ }
+
+ return (
+
+
+
+ {/* Top Bar */}
+
+
+
+
+ {modeConfig.icon}
+ {modeConfig.displayName}
+
+
+
+ {formatDuration(state.duration)}
+
+
+
+
+
+
+
+
+ {/* Transcript â reads from reducer state */}
+
+
+
+
+ {/* Counter Strategy Card â only shown when tactic is detected */}
+ {activeStrategy && state.tactic != null && (
+
+
+
+ )}
+
+ {/* Bottom Actions */}
+
+
+ Cancel
+
+
+
+ âšī¸
+ Stop Session
+
+
+
+
+ {state.audioLevel > 0 && (
+
+
+
+ )}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: { flex: 1, backgroundColor: AppColors.primaryLight },
+ loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32 },
+ loadingIconCircle: { width: 80, height: 80, borderRadius: 28, backgroundColor: '#EDE9FE', justifyContent: 'center', alignItems: 'center', marginBottom: 24 },
+ loadingIcon: { fontSize: 36 },
+ loadingTitle: { fontSize: 22, fontWeight: '700', color: AppColors.textPrimary, marginBottom: 8, textAlign: 'center' },
+ loadingText: { fontSize: 15, color: AppColors.textSecondary, marginBottom: 32, textAlign: 'center' },
+ loadingBarBg: { width: '60%', height: 6, borderRadius: 3, backgroundColor: '#EDE9FE', overflow: 'hidden' },
+ loadingBar: { width: '45%', height: '100%', borderRadius: 3 },
+
+ topBar: { paddingTop: 50, paddingBottom: 16, paddingHorizontal: 20, backgroundColor: '#FFFFFF', borderBottomLeftRadius: 24, borderBottomRightRadius: 24, elevation: 4, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.06, shadowRadius: 8 },
+ topBarContent: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
+ topBarLeft: { flex: 1 },
+ modeTag: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#EDE9FE', paddingVertical: 6, paddingHorizontal: 12, borderRadius: 10, alignSelf: 'flex-start', marginBottom: 12 },
+ modeIconText: { fontSize: 16, marginRight: 6 },
+ modeText: { fontSize: 13, fontWeight: '600', color: AppColors.accentPrimary },
+ recordingIndicator: { flexDirection: 'row', alignItems: 'center' },
+ recordingDot: { width: 12, height: 12, borderRadius: 6, backgroundColor: AppColors.error, marginRight: 8 },
+ recordingText: { fontSize: 24, fontWeight: '700', color: AppColors.textPrimary, fontVariant: ['tabular-nums'] },
+ topBarRight: { marginLeft: 16 },
+
+ transcriptContainer: { flex: 1, backgroundColor: AppColors.primaryLight, minHeight: 150 },
+
+ counterStrategyPanel: { backgroundColor: '#FFFFFF', paddingTop: 12, paddingBottom: 4, borderTopWidth: 1, borderTopColor: AppColors.accentViolet + '20', maxHeight: '45%' },
+
+ suggestionsPanel: { backgroundColor: '#FFFFFF', paddingVertical: 16, borderTopWidth: 1, borderTopColor: '#F3F4F6', maxHeight: 300 },
+ suggestionsTitle: { fontSize: 16, fontWeight: '700', color: AppColors.textPrimary, marginHorizontal: 16, marginBottom: 12 },
+
+ bottomActions: { flexDirection: 'row', padding: 16, paddingBottom: 28, backgroundColor: '#FFFFFF', borderTopWidth: 1, borderTopColor: '#F3F4F6', gap: 12 },
+ cancelButton: { flex: 1, padding: 16, backgroundColor: '#F3F4F6', borderRadius: 16, alignItems: 'center' },
+ cancelButtonText: { fontSize: 16, fontWeight: '600', color: AppColors.textSecondary },
+ stopButton: { flex: 2, borderRadius: 16, overflow: 'hidden', elevation: 4, shadowColor: AppColors.error, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.3, shadowRadius: 6 },
+ stopButtonGradient: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', padding: 16 },
+ stopButtonIcon: { fontSize: 20, marginRight: 8 },
+ stopButtonText: { fontSize: 16, fontWeight: '700', color: '#FFFFFF' },
+
+ audioVisualizer: { position: 'absolute', bottom: 0, left: 0, right: 0, height: 3, backgroundColor: '#EDE9FE' },
+ audioBar: { height: '100%', borderRadius: 2 },
+});
diff --git a/src/screens/OutcomeReplayScreen.tsx b/src/screens/OutcomeReplayScreen.tsx
new file mode 100644
index 0000000..4095f8c
--- /dev/null
+++ b/src/screens/OutcomeReplayScreen.tsx
@@ -0,0 +1,263 @@
+import React, { useEffect, useState } from 'react';
+import { View, Text, StyleSheet, ScrollView, ActivityIndicator, TouchableOpacity } from 'react-native';
+import { RouteProp } from '@react-navigation/native';
+import { StackNavigationProp } from '@react-navigation/stack';
+import LinearGradient from 'react-native-linear-gradient';
+import { RootStackParamList } from '../navigation/types';
+import { AppColors } from '../theme';
+import { useSessionAnalyzer } from '../hooks/useSessionAnalyzer';
+import { OutcomeReplayEngine, ReplaySimulationResult } from '../ai/OutcomeReplayEngine';
+import { BehavioralAnalyticsEngine, BehavioralProfile } from '../ai/BehavioralAnalyticsEngine';
+
+type OutcomeReplayScreenRouteProp = RouteProp;
+type OutcomeReplayScreenNavigationProp = StackNavigationProp;
+
+interface OutcomeReplayScreenProps {
+ route: OutcomeReplayScreenRouteProp;
+ navigation: OutcomeReplayScreenNavigationProp;
+}
+
+export const OutcomeReplayScreen: React.FC = ({ route, navigation }) => {
+ const { sessionId } = route.params;
+ const { getSession } = useSessionAnalyzer();
+ const [loading, setLoading] = useState(true);
+ const [simulation, setSimulation] = useState(null);
+ const [profile, setProfile] = useState(null);
+
+ useEffect(() => {
+ const loadEngines = async () => {
+ if (!sessionId) {
+ setLoading(false);
+ return;
+ }
+ const session = await getSession(sessionId);
+ if (session) {
+ setSimulation(OutcomeReplayEngine.generateSimulation(session));
+ setProfile(BehavioralAnalyticsEngine.analyzeTranscript(session));
+ }
+ setLoading(false);
+ };
+ loadEngines();
+ }, [sessionId, getSession]);
+
+ if (loading || !simulation || !profile) {
+ return (
+
+
+ Running Counterfactual Simulations...
+
+ );
+ }
+
+ return (
+
+
+
+ {/* Header */}
+
+ SESSION ANALYSIS
+ Strategic Outcome Replayâĸ
+
+
+ {/* SECTION 3: Behavioral Performance Profile */}
+
+ Behavioral Performance Profile
+
+
+ {profile.archetype.map((trait, index) => (
+
+ {trait}
+
+ ))}
+
+
+
+
+ Leverage Score
+ 70 ? AppColors.success : AppColors.error }]}>
+ {profile.leverageCaptureScore}%
+
+
+
+ Hesitations
+ {profile.hesitationMoments}
+
+
+ Filler Words
+ {profile.fillerWordCount}
+
+
+
+
+
+ {/* SECTION 4: Post-Session Summary */}
+
+ Tactical Post-Session Summary
+
+
+ What Worked
+ {simulation.postSessionSummary.whatWorked.map((item, idx) => (
+
+ â
+ {item}
+
+ ))}
+
+
+
+ Signals of Interest
+ {simulation.postSessionSummary.signalsOfInterest.map((item, idx) => (
+
+ âĸ
+ {item}
+
+ ))}
+
+
+
+ Hidden Objections Detected
+ {simulation.postSessionSummary.hiddenObjections.map((item, idx) => (
+
+ !
+ {item}
+
+ ))}
+
+
+
+ Follow-up Strategy
+
+ {simulation.postSessionSummary.followUpStrategy}
+
+
+
+
+ {/* SECTION 1 & 2: Tactical Missed Opportunities & Improvement Simulation */}
+
+ Tactical Counterfactuals
+
+ {simulation.opportunities.length === 0 ? (
+
+ No major tactical errors detected.
+
+ ) : (
+ simulation.opportunities.map((opp, idx) => (
+
+ {/* Type Label */}
+
+ {opp.tacticType.replace('_', ' ')}
+
+
+ {/* Original String */}
+ Original Response
+ "{opp.originalQuote}"
+
+ {/* Improved Sim */}
+
+ Improved Strategic Framing
+ "{opp.improvedReframing}"
+
+
+ Persuasion Strength:
+
+ {opp.originalStrengthScore}%
+ {' â '}
+ {opp.improvedStrengthScore}%
+
+
+
+
+ ))
+ )}
+
+
+ {/* Next Session Reccomendations */}
+ navigation.navigate('Home')}
+ >
+ Return to Dashboard
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: { flex: 1, backgroundColor: '#0A0E1A' },
+ loadingContainer: { flex: 1, backgroundColor: '#0A0E1A', justifyContent: 'center', alignItems: 'center' },
+ loadingText: { color: '#8B949E', marginTop: 16, fontSize: 16 },
+ gradient: { flex: 1 },
+ scrollView: { flex: 1 },
+ scrollContent: { padding: 24, paddingBottom: 60, paddingTop: 60 },
+ header: { marginBottom: 32 },
+ subtitle: { color: '#8B949E', fontSize: 13, fontWeight: '700', letterSpacing: 1.5, marginBottom: 4 },
+ title: { fontSize: 28, fontWeight: '800', color: '#FFFFFF', letterSpacing: 0.5 },
+ section: { marginBottom: 36 },
+ sectionHeader: { fontSize: 20, fontWeight: '700', color: '#E6EDF3', marginBottom: 16 },
+
+ // Profile
+ profileCard: { backgroundColor: '#161B22', borderRadius: 16, padding: 20, borderWidth: 1, borderColor: '#30363D' },
+ archetypeContainer: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, marginBottom: 20 },
+ badge: { backgroundColor: 'rgba(123, 97, 255, 0.15)', paddingHorizontal: 12, paddingVertical: 6, borderRadius: 12, borderWidth: 1, borderColor: 'rgba(123, 97, 255, 0.3)' },
+ badgeText: { color: '#B19CFF', fontSize: 13, fontWeight: '600' },
+ statsRow: { flexDirection: 'row', justifyContent: 'space-between', borderTopWidth: 1, borderTopColor: '#30363D', paddingTop: 16 },
+ statBox: { alignItems: 'center' },
+ statLabel: { color: '#8B949E', fontSize: 12, marginBottom: 4 },
+ statValue: { fontSize: 24, fontWeight: '800' },
+ statValueAlt: { color: '#E6EDF3', fontSize: 24, fontWeight: '700' },
+
+ // Counterfactuals
+ simulationCard: { backgroundColor: '#161B22', borderRadius: 16, padding: 20, borderWidth: 1, borderColor: '#30363D', marginBottom: 16 },
+ tacticLabelContainer: { alignSelf: 'flex-start', backgroundColor: '#30363D', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 8, marginBottom: 16 },
+ tacticLabelText: { color: '#C9D1D9', fontSize: 11, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 1 },
+ oppTitle: { color: '#8B949E', fontSize: 12, fontWeight: '700', textTransform: 'uppercase', marginBottom: 8 },
+ originalQuote: { color: '#E6EDF3', fontSize: 16, fontStyle: 'italic', lineHeight: 24, marginBottom: 20 },
+ improvedBox: { borderRadius: 12, padding: 16, borderWidth: 1, borderColor: 'rgba(16, 185, 129, 0.2)' },
+ oppTitleGood: { color: '#10B981', fontSize: 12, fontWeight: '700', textTransform: 'uppercase', marginBottom: 8 },
+ improvedQuote: { color: '#FFFFFF', fontSize: 16, fontWeight: '600', lineHeight: 24, marginBottom: 16 },
+ deltaBox: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: 'rgba(0,0,0,0.2)', padding: 10, borderRadius: 8 },
+ deltaLabel: { color: '#8B949E', fontSize: 13, fontWeight: '600' },
+ deltaScores: { fontSize: 16, fontWeight: '700' },
+ deltaBad: { color: AppColors.error },
+ deltaGood: { color: '#10B981' },
+
+ emptyCard: { padding: 20, backgroundColor: '#161B22', borderRadius: 12, alignItems: 'center' },
+ emptyText: { color: '#8B949E' },
+
+ doneButton: { backgroundColor: AppColors.accentPrimary, borderRadius: 12, paddingVertical: 16, alignItems: 'center', marginTop: 10 },
+ doneButtonText: {
+ color: '#FFFFFF',
+ fontSize: 16,
+ fontWeight: '700',
+ },
+ bulletRow: {
+ flexDirection: 'row',
+ marginBottom: 8,
+ },
+ bulletText: {
+ color: '#e0e0e0',
+ fontSize: 15,
+ flex: 1,
+ lineHeight: 22,
+ },
+ divider: {
+ height: 1,
+ backgroundColor: '#333333',
+ marginVertical: 12,
+ },
+
+ // Summary Card
+ summaryCard: { backgroundColor: '#161B22', borderRadius: 16, padding: 20, borderWidth: 1, borderColor: '#30363D' },
+ summaryLabel: { color: '#8B949E', fontSize: 13, fontWeight: '700', textTransform: 'uppercase', marginBottom: 12 },
+ bulletPointSuccess: { color: '#10B981', fontSize: 16, marginRight: 10, marginTop: 1, fontWeight: '700' },
+ bulletPointPrimary: { color: AppColors.accentPrimary, fontSize: 16, marginRight: 10, marginTop: 1, fontWeight: '700' },
+ bulletPointWarning: { color: '#F59E0B', fontSize: 16, marginRight: 10, marginTop: 1, fontWeight: '700' },
+ strategyBox: { borderRadius: 12, padding: 16, borderWidth: 1, borderColor: 'rgba(123, 97, 255, 0.3)' },
+ strategyText: { color: '#FFFFFF', fontSize: 15, fontWeight: '600', lineHeight: 22 },
+});
diff --git a/src/screens/PreSessionFormScreen.tsx b/src/screens/PreSessionFormScreen.tsx
new file mode 100644
index 0000000..cb8ba41
--- /dev/null
+++ b/src/screens/PreSessionFormScreen.tsx
@@ -0,0 +1,221 @@
+import React, { useState, useEffect } from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ ScrollView,
+ TextInput,
+ TouchableOpacity,
+ KeyboardAvoidingView,
+ Platform,
+ Alert,
+} from 'react-native';
+import { RouteProp } from '@react-navigation/native';
+import { StackNavigationProp } from '@react-navigation/stack';
+import LinearGradient from 'react-native-linear-gradient';
+import { RootStackParamList } from '../navigation/types';
+import { AppColors } from '../theme';
+import { StrategicPreparationEngine, FormField } from '../ai/StrategicPreparationEngine';
+import { getModeConfig } from '../ai/patternLibrary';
+import { PreSessionInputs } from '../types/session';
+
+type PreSessionFormRouteProp = RouteProp;
+type PreSessionFormNavigationProp = StackNavigationProp;
+
+interface Props {
+ route: PreSessionFormRouteProp;
+ navigation: PreSessionFormNavigationProp;
+}
+
+export const PreSessionFormScreen: React.FC = ({ route, navigation }) => {
+ const { mode } = route.params;
+ const modeConfig = getModeConfig(mode);
+
+ const [fields, setFields] = useState([]);
+ const [formValues, setFormValues] = useState({});
+
+ useEffect(() => {
+ const generatedFields = StrategicPreparationEngine.getFormConfigForMode(mode);
+ setFields(generatedFields);
+
+ // Initialize empty form state
+ const initialValues: PreSessionInputs = {};
+ generatedFields.forEach(f => {
+ initialValues[f.id] = '';
+ });
+ setFormValues(initialValues);
+ }, [mode]);
+
+ const handleInputChange = (id: string, text: string) => {
+ setFormValues(prev => ({
+ ...prev,
+ [id]: text
+ }));
+ };
+
+ const handleGenerateStrategy = () => {
+ // Basic validation
+ const missingRequired = fields.filter(f => f.required && !formValues[f.id].trim());
+
+ if (missingRequired.length > 0) {
+ Alert.alert(
+ 'Missing Information',
+ `Please fill out: ${missingRequired.map(f => f.label).join(', ')}`
+ );
+ return;
+ }
+
+ // Generate strict 10-point analysis based on input
+ const analysis = StrategicPreparationEngine.generateStrategicAnalysis(mode, formValues);
+
+ navigation.navigate('PreSessionStrategy', {
+ mode,
+ inputs: formValues,
+ analysis,
+ });
+ };
+
+ return (
+
+
+
+
+
+
+ {modeConfig.icon}
+
+ STRATEGIC PREPARATION
+ {modeConfig.displayName}
+
+ Fill out this briefing document to instantly generate your 10-point power positioning and tactical response strategy.
+
+
+
+
+ {fields.map((field) => (
+
+
+ {field.label}
+ {!field.required && (Optional)}
+
+
+ handleInputChange(field.id, text)}
+ placeholder={field.placeholder}
+ placeholderTextColor="#4B5563"
+ multiline={field.type === 'multiline'}
+ numberOfLines={field.type === 'multiline' ? 3 : 1}
+ keyboardType={field.type === 'number' ? 'numeric' : 'default'}
+ />
+
+ ))}
+
+
+
+
+ Generate Tactical Strategy âĄ
+
+
+
+ navigation.replace('LiveSession', { mode })}
+ activeOpacity={0.7}
+ >
+ Skip Setup & Start Session đ
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: { flex: 1, backgroundColor: '#0A0E1A' },
+ gradient: { flex: 1 },
+ scrollView: { flex: 1 },
+ scrollContent: { padding: 24, paddingBottom: 60, paddingTop: 60 },
+
+ header: { marginBottom: 32, alignItems: 'center' },
+ iconContainer: {
+ width: 64, height: 64, borderRadius: 32,
+ backgroundColor: 'rgba(123, 97, 255, 0.15)',
+ justifyContent: 'center', alignItems: 'center',
+ marginBottom: 16, borderWidth: 1, borderColor: 'rgba(123, 97, 255, 0.3)'
+ },
+ headerIcon: { fontSize: 32 },
+ subtitle: { color: AppColors.accentPrimary, fontSize: 13, fontWeight: '800', letterSpacing: 1.5, marginBottom: 8 },
+ title: { fontSize: 32, fontWeight: '800', color: '#FFFFFF', marginBottom: 12, textAlign: 'center' },
+ description: { fontSize: 15, color: '#8B949E', textAlign: 'center', lineHeight: 22 },
+
+ formContainer: { marginBottom: 20 },
+ inputGroup: { marginBottom: 20 },
+ labelRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
+ label: { color: '#E6EDF3', fontSize: 15, fontWeight: '600' },
+ optionalText: { color: '#4B5563', fontSize: 13, marginLeft: 8 },
+
+ input: {
+ backgroundColor: '#161B22',
+ borderWidth: 1,
+ borderColor: '#30363D',
+ borderRadius: 12,
+ color: '#FFFFFF',
+ fontSize: 16,
+ paddingHorizontal: 16,
+ paddingVertical: 14,
+ },
+ inputMultiline: {
+ minHeight: 100,
+ textAlignVertical: 'top',
+ },
+
+ generateButton: {
+ borderRadius: 16,
+ overflow: 'hidden',
+ marginTop: 10,
+ elevation: 8,
+ shadowColor: '#10B981',
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.3,
+ shadowRadius: 10,
+ },
+ generateGradient: {
+ paddingVertical: 18,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ generateButtonText: {
+ color: '#FFFFFF',
+ fontSize: 18,
+ fontWeight: '800',
+ },
+ skipButton: {
+ paddingVertical: 16,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginTop: 8,
+ },
+ skipButtonText: {
+ color: '#8B949E',
+ fontSize: 16,
+ fontWeight: '600',
+ },
+});
diff --git a/src/screens/PreSessionStrategyScreen.tsx b/src/screens/PreSessionStrategyScreen.tsx
new file mode 100644
index 0000000..fca772c
--- /dev/null
+++ b/src/screens/PreSessionStrategyScreen.tsx
@@ -0,0 +1,218 @@
+import React from 'react';
+import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native';
+import { RouteProp } from '@react-navigation/native';
+import { StackNavigationProp } from '@react-navigation/stack';
+import LinearGradient from 'react-native-linear-gradient';
+import { RootStackParamList } from '../navigation/types';
+import { AppColors } from '../theme';
+import { getModeConfig } from '../ai/patternLibrary';
+
+type PreSessionStrategyRouteProp = RouteProp;
+type PreSessionStrategyNavigationProp = StackNavigationProp;
+
+interface Props {
+ route: PreSessionStrategyRouteProp;
+ navigation: PreSessionStrategyNavigationProp;
+}
+
+export const PreSessionStrategyScreen: React.FC = ({ route, navigation }) => {
+ const { mode, inputs, analysis } = route.params;
+ const modeConfig = getModeConfig(mode);
+
+ const handleStartSession = () => {
+ // Navigate to actual Live Recording passing the pre-session analysis forward
+ navigation.replace('LiveSession', {
+ mode,
+ preSessionInputs: inputs,
+ strategicAnalysis: analysis
+ });
+ };
+
+ return (
+
+
+
+
+
+ OFFLINE INTELLIGENCE MODE
+ Strategic Plan: {modeConfig.displayName}
+
+
+ {/* 1. Power Positioning summary */}
+
+ 1. Power Positioning
+
+ {analysis.powerPositioning}
+
+
+
+ {/* 2. Opening Script */}
+
+ 2. Opening Statement Script
+
+ {analysis.openingScript}
+
+
+
+ {/* 3 & 4. Objections & Responses */}
+
+ 3. Likely Objections & Hard Counters
+ {analysis.likelyObjections.map((obj, i) => (
+
+ If they say:
+ {obj}
+
+
+
+ Strategic Counter:
+ {analysis.recommendedResponses[i]}
+
+ ))}
+
+
+ {/* 5. Target Phrases */}
+
+ 4. High-Impact Phrases to Inject
+
+ {analysis.highImpactPhrases.map((phrase, i) => (
+
+ {phrase}
+
+ ))}
+
+
+
+ {/* 6. Words to Avoid */}
+
+ 5. Phrases to Avoid
+
+ {analysis.phrasesToAvoid.map((phrase, i) => (
+
+ {phrase}
+
+ ))}
+
+
+
+ {/* 7. Psychological Tactics */}
+
+ 6. Opponent's Psychological Tactics
+
+ {analysis.psychologicalTactics.map((tactic, i) => (
+
+ âĸ
+ {tactic}
+
+ ))}
+
+
+
+ {/* 8. Confidence Triggers */}
+
+ 7. Confidence Triggers
+
+ {analysis.confidenceTriggers.map((tactic, i) => (
+
+ âĸ
+ {tactic}
+
+ ))}
+
+
+
+ {/* 9. Mistakes to Avoid */}
+
+ 8. Fatal Mistakes To Avoid
+
+ {analysis.mistakesToAvoid.map((mistake, i) => (
+
+ !
+ {mistake}
+
+ ))}
+
+
+
+ {/* 10. Closing Script */}
+
+ 9. Closing Script (To force commitment)
+
+ {analysis.closingScript}
+
+
+
+ {/* ACTION BUTTON */}
+
+
+
+ Begin Target Session âē
+ Start local microphone tracking
+
+
+
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: { flex: 1, backgroundColor: '#0A0E1A' },
+ gradient: { flex: 1 },
+ scrollView: { flex: 1 },
+ scrollContent: { padding: 24, paddingBottom: 60, paddingTop: 60 },
+
+ header: { marginBottom: 32 },
+ subtitle: { color: AppColors.accentPrimary, fontSize: 13, fontWeight: '800', letterSpacing: 1.5, marginBottom: 8 },
+ title: { fontSize: 32, fontWeight: '800', color: '#FFFFFF', marginBottom: 12 },
+
+ section: { marginBottom: 32 },
+ sectionHeader: { fontSize: 18, fontWeight: '700', color: '#E6EDF3', marginBottom: 16 },
+
+ cardPrimary: { backgroundColor: '#161B22', borderRadius: 16, padding: 20, borderWidth: 1, borderColor: '#30363D' },
+ cardTextPrimary: { color: '#FFFFFF', fontSize: 16, lineHeight: 24, fontWeight: '500' },
+
+ scriptBox: { borderRadius: 12, padding: 20, borderWidth: 1, borderColor: 'rgba(123, 97, 255, 0.3)' },
+ scriptText: { color: '#FFFFFF', fontSize: 18, fontStyle: 'italic', fontWeight: 'bold', lineHeight: 26 },
+
+ objectionCard: { backgroundColor: '#161B22', padding: 20, borderRadius: 16, borderWidth: 1, borderColor: '#30363D', marginBottom: 16 },
+ objectionLabel: { color: '#F87171', fontSize: 12, fontWeight: '700', textTransform: 'uppercase', marginBottom: 6 },
+ objectionText: { color: '#FFFFFF', fontSize: 16, fontStyle: 'italic', marginBottom: 16 },
+ divider: { height: 1, backgroundColor: '#30363D', marginBottom: 16 },
+ responseLabel: { color: '#10B981', fontSize: 12, fontWeight: '700', textTransform: 'uppercase', marginBottom: 6 },
+ responseText: { color: '#FFFFFF', fontSize: 16, fontWeight: '600', lineHeight: 24 },
+
+ wrapContainer: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
+ goodBadge: { backgroundColor: 'rgba(16, 185, 129, 0.1)', paddingHorizontal: 14, paddingVertical: 10, borderRadius: 8, borderWidth: 1, borderColor: 'rgba(16, 185, 129, 0.3)' },
+ goodBadgeText: { color: '#10B981', fontSize: 14, fontWeight: '600' },
+
+ badBadge: { backgroundColor: 'rgba(239, 68, 68, 0.1)', paddingHorizontal: 14, paddingVertical: 10, borderRadius: 8, borderWidth: 1, borderColor: 'rgba(239, 68, 68, 0.3)' },
+ badBadgeText: { color: '#F87171', fontSize: 14, fontWeight: '600', textDecorationLine: 'line-through' },
+
+ cardSecondary: { backgroundColor: '#161B22', borderRadius: 16, padding: 20, borderWidth: 1, borderColor: '#30363D' },
+ cardError: { backgroundColor: 'rgba(239, 68, 68, 0.05)', borderRadius: 16, padding: 20, borderWidth: 1, borderColor: 'rgba(239, 68, 68, 0.2)' },
+
+ bulletRow: { flexDirection: 'row', alignItems: 'flex-start', marginBottom: 12 },
+ bulletPoint: { color: '#8B949E', fontSize: 16, marginRight: 10, marginTop: 2 },
+ bulletText: { color: '#C9D1D9', fontSize: 15, lineHeight: 22, flex: 1 },
+
+ errorBulletPoint: { color: '#F87171', fontSize: 16, marginRight: 10, fontWeight: 'bold' },
+ errorText: { color: '#F87171', fontSize: 15, lineHeight: 22, flex: 1, fontWeight: '500' },
+
+ startButton: { borderRadius: 16, overflow: 'hidden', elevation: 8, shadowColor: '#E11D48', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 10 },
+ startGradient: { paddingVertical: 20, alignItems: 'center', justifyContent: 'center' },
+ buttonStack: { alignItems: 'center' },
+ startButtonText: { color: '#FFFFFF', fontSize: 20, fontWeight: '800', marginBottom: 4 },
+ startButtonSubtext: { color: 'rgba(255, 255, 255, 0.7)', fontSize: 13, fontWeight: '600' }
+});
diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx
new file mode 100644
index 0000000..7b1148f
--- /dev/null
+++ b/src/screens/SettingsScreen.tsx
@@ -0,0 +1,220 @@
+import React, { useState, useEffect } from 'react';
+import {
+ View, Text, ScrollView, StyleSheet, TouchableOpacity, Alert, Switch, StatusBar,
+} from 'react-native';
+import LinearGradient from 'react-native-linear-gradient';
+import { StackNavigationProp } from '@react-navigation/stack';
+import { AppColors } from '../theme';
+import { RootStackParamList } from '../navigation/types';
+import { LocalStorageService } from '../services/LocalStorageService';
+import { AppSettings, NegotiationMode } from '../types/session';
+import { getModeConfig } from '../ai/patternLibrary';
+
+type SettingsScreenProps = { navigation: StackNavigationProp };
+
+export const SettingsScreen: React.FC = ({ navigation }) => {
+ const [settings, setSettings] = useState(null);
+ const [storageInfo, setStorageInfo] = useState<{ keys: number; estimatedSize: string } | null>(null);
+
+ useEffect(() => { loadSettings(); loadStorageInfo(); }, []);
+ const loadSettings = async () => { setSettings(await LocalStorageService.getSettings()); };
+ const loadStorageInfo = async () => { setStorageInfo(await LocalStorageService.getStorageInfo()); };
+ const saveSettings = async (s: AppSettings) => { await LocalStorageService.saveSettings(s); setSettings(s); };
+
+ const handleClearData = () => {
+ Alert.alert('Clear All Data', 'Delete all sessions? This cannot be undone.', [
+ { text: 'Cancel', style: 'cancel' },
+ { text: 'Delete All', style: 'destructive', onPress: async () => { await LocalStorageService.clearAllData(); await loadStorageInfo(); Alert.alert('Success', 'All data cleared'); } },
+ ]);
+ };
+
+ if (!settings) return Loading...;
+ const modeConfig = getModeConfig(settings.defaultMode);
+
+ return (
+
+
+
+
+
+ Settings
+ Customize your Latent experience
+
+
+ {/* Mode Section */}
+
+ Default Mode
+ {
+ Alert.alert('Select Default Mode', 'Choose your default mode',
+ [NegotiationMode.JOB_INTERVIEW, NegotiationMode.SALES, NegotiationMode.STARTUP_PITCH, NegotiationMode.SALARY_RAISE]
+ .map((m) => ({ text: getModeConfig(m).displayName, onPress: () => saveSettings({ ...settings, defaultMode: m }) }))
+ );
+ }}>
+ {modeConfig.icon}
+
+ {modeConfig.displayName}
+ {modeConfig.description}
+
+ âē
+
+
+
+ {/* Pattern Detection */}
+
+ Pattern Detection
+
+
+ Sensitivity
+
+ {settings.patternSensitivity < 0.8 ? 'Low - Fewer false positives' : settings.patternSensitivity <= 1.2 ? 'Normal - Balanced' : 'High - More patterns'}
+
+
+
+ {[{ label: 'Low', val: 0.7, check: settings.patternSensitivity <= 0.7 },
+ { label: 'Normal', val: 1.0, check: settings.patternSensitivity === 1.0 },
+ { label: 'High', val: 1.3, check: settings.patternSensitivity >= 1.3 }].map((b) => (
+ saveSettings({ ...settings, patternSensitivity: b.val })}>
+ {b.label}
+
+ ))}
+
+
+
+
+ {/* Session Settings */}
+
+ Session Settings
+ {[
+ { label: 'Auto-Save', desc: 'Saves session every 45 seconds', key: 'enableAutoSave' as const },
+ { label: 'Haptic Feedback', desc: 'Vibrate on pattern detection', key: 'enableHapticFeedback' as const },
+ { label: 'Suggestion Notifications', desc: 'Show notifications', key: 'enableSuggestionNotifications' as const },
+ ].map((s) => (
+
+
+ {s.label}
+ {s.desc}
+
+ saveSettings({ ...settings, [s.key]: v })}
+ trackColor={{ false: '#E5E7EB', true: '#C4B5FD' }} thumbColor={settings[s.key] ? '#7B61FF' : '#D1D5DB'} />
+
+ ))}
+
+
+ {/* Debug */}
+
+ đ Debug & Testing
+
+
+ Debug Mode
+ Use test transcripts
+
+ {
+ saveSettings({ ...settings, debugMode: v });
+ if (v) Alert.alert('Debug Mode Enabled', 'Hardcoded transcripts will be used. Restart your session.', [{ text: 'Got it' }]);
+ }} trackColor={{ false: '#E5E7EB', true: '#FDE68A' }} thumbColor={settings.debugMode ? '#F59E0B' : '#D1D5DB'} />
+
+ {settings.debugMode && (
+ â ī¸ Debug mode active. Test transcripts injected every 7s.
+ )}
+
+
+ {/* Storage */}
+
+ Storage
+ {storageInfo && (
+
+ Sessions{storageInfo.keys}
+
+ Used{storageInfo.estimatedSize}
+
+ )}
+
+ Clear All Data
+
+
+
+ {/* Privacy */}
+
+ đ
+
+ 100% Private & Offline
+ All data stays on your device. No cloud.
+
+
+
+ {/* About */}
+
+
+ L
+
+ Latent
+ Offline Meeting Intelligence
+ Version 1.0.0
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: { flex: 1, backgroundColor: AppColors.primaryLight },
+ gradient: { flex: 1 },
+ centered: { justifyContent: 'center', alignItems: 'center' },
+ loadingText: { fontSize: 16, color: AppColors.textSecondary },
+ scrollView: { flex: 1 },
+ scrollContent: { padding: 24, paddingTop: 56, paddingBottom: 40 },
+
+ header: { marginBottom: 28 },
+ title: { fontSize: 32, fontWeight: '700', color: AppColors.textPrimary, marginBottom: 4 },
+ subtitle: { fontSize: 14, color: AppColors.textSecondary },
+
+ section: { marginBottom: 28 },
+ sectionTitle: { fontSize: 16, fontWeight: '700', color: AppColors.textPrimary, marginBottom: 14 },
+
+ modeButton: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#FFFFFF', padding: 16, borderRadius: 18, elevation: 2, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 4 },
+ modeIconCircle: { width: 48, height: 48, borderRadius: 16, backgroundColor: '#EDE9FE', justifyContent: 'center', alignItems: 'center', marginRight: 14 },
+ modeIcon: { fontSize: 24 },
+ modeInfo: { flex: 1 },
+ modeName: { fontSize: 16, fontWeight: '600', color: AppColors.textPrimary, marginBottom: 3 },
+ modeDesc: { fontSize: 13, color: AppColors.textSecondary, lineHeight: 17 },
+ chevron: { fontSize: 24, color: AppColors.textMuted },
+
+ settingRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', backgroundColor: '#FFFFFF', padding: 16, borderRadius: 18, marginBottom: 10, elevation: 2, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.04, shadowRadius: 4 },
+ settingInfo: { flex: 1, marginRight: 16 },
+ settingLabel: { fontSize: 15, fontWeight: '600', color: AppColors.textPrimary, marginBottom: 3 },
+ settingDesc: { fontSize: 12, color: AppColors.textSecondary, lineHeight: 16 },
+
+ sensitivityBtns: { flexDirection: 'row', gap: 6 },
+ sensBtn: { paddingHorizontal: 14, paddingVertical: 8, borderRadius: 12, backgroundColor: '#F3F4F6' },
+ sensBtnActive: { backgroundColor: '#7B61FF' },
+ sensBtnText: { fontSize: 12, fontWeight: '600', color: AppColors.textSecondary },
+ sensBtnTextActive: { color: '#FFFFFF' },
+
+ storageCard: { backgroundColor: '#FFFFFF', padding: 16, borderRadius: 18, marginBottom: 14, elevation: 2, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.04, shadowRadius: 4 },
+ storageRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 10 },
+ storageDivider: { height: 1, backgroundColor: '#F3F4F6' },
+ storageLabel: { fontSize: 14, color: AppColors.textSecondary },
+ storageValue: { fontSize: 14, fontWeight: '600', color: AppColors.textPrimary },
+
+ dangerButton: { backgroundColor: '#FEE2E2', padding: 16, borderRadius: 16, alignItems: 'center' },
+ dangerButtonText: { fontSize: 15, fontWeight: '600', color: '#DC2626' },
+
+ debugNotice: { backgroundColor: '#FEF3C7', padding: 14, borderRadius: 14, marginTop: 10, borderLeftWidth: 3, borderLeftColor: '#F59E0B' },
+ debugNoticeText: { fontSize: 12, color: '#92400E', lineHeight: 16 },
+
+ privacyCard: { flexDirection: 'row', backgroundColor: '#FFFFFF', padding: 18, borderRadius: 20, marginBottom: 28, alignItems: 'center', elevation: 2, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 6 },
+ privacyIconCircle: { width: 44, height: 44, borderRadius: 14, backgroundColor: '#EDE9FE', justifyContent: 'center', alignItems: 'center', marginRight: 14 },
+ privacyIcon: { fontSize: 22 },
+ privacyText: { flex: 1 },
+ privacyTitle: { fontSize: 15, fontWeight: '600', color: AppColors.textPrimary, marginBottom: 3 },
+ privacyDesc: { fontSize: 12, color: AppColors.textSecondary, lineHeight: 17 },
+
+ aboutSection: { alignItems: 'center', paddingTop: 8, paddingBottom: 20 },
+ aboutLogo: { width: 56, height: 56, borderRadius: 18, justifyContent: 'center', alignItems: 'center', marginBottom: 12, elevation: 4, shadowColor: '#7B61FF', shadowOffset: { width: 0, height: 3 }, shadowOpacity: 0.3, shadowRadius: 8 },
+ aboutLogoText: { fontSize: 24, fontWeight: '700', color: '#FFFFFF' },
+ aboutTitle: { fontSize: 22, fontWeight: '700', color: AppColors.textPrimary, marginBottom: 4 },
+ aboutSubtitle: { fontSize: 14, color: '#7B61FF', marginBottom: 8 },
+ aboutVersion: { fontSize: 12, color: AppColors.textMuted },
+});
diff --git a/src/screens/SpeechToTextScreen.tsx b/src/screens/SpeechToTextScreen.tsx
deleted file mode 100644
index ab1aad7..0000000
--- a/src/screens/SpeechToTextScreen.tsx
+++ /dev/null
@@ -1,425 +0,0 @@
-import React, { useState, useRef, useEffect } from 'react';
-import {
- View,
- Text,
- TouchableOpacity,
- ScrollView,
- StyleSheet,
- NativeModules,
- Alert,
- Platform,
- PermissionsAndroid,
-} from 'react-native';
-import LinearGradient from 'react-native-linear-gradient';
-import { RunAnywhere } from '@runanywhere/core';
-import { AppColors } from '../theme';
-import { useModelService } from '../services/ModelService';
-import { ModelLoaderWidget, AudioVisualizer } from '../components';
-
-// Native Audio Module - records in WAV format (16kHz mono) optimal for Whisper STT
-const { NativeAudioModule } = NativeModules;
-
-export const SpeechToTextScreen: React.FC = () => {
- const modelService = useModelService();
- const [isRecording, setIsRecording] = useState(false);
- const [isTranscribing, setIsTranscribing] = useState(false);
- const [transcription, setTranscription] = useState('');
- const [transcriptionHistory, setTranscriptionHistory] = useState([]);
- const [audioLevel, setAudioLevel] = useState(0);
- const [recordingDuration, setRecordingDuration] = useState(0);
- const recordingPathRef = useRef(null);
- const audioLevelIntervalRef = useRef | null>(null);
- const recordingStartRef = useRef(0);
-
- // Cleanup on unmount
- useEffect(() => {
- return () => {
- if (audioLevelIntervalRef.current) {
- clearInterval(audioLevelIntervalRef.current);
- }
- if (isRecording && NativeAudioModule) {
- NativeAudioModule.cancelRecording().catch(() => {});
- }
- };
- }, [isRecording]);
-
- const startRecording = async () => {
- try {
- // Check if native module is available
- if (!NativeAudioModule) {
- console.error('[STT] NativeAudioModule not available');
- Alert.alert('Error', 'Native audio module not available. Please rebuild the app.');
- return;
- }
-
- // Request microphone permission on Android
- if (Platform.OS === 'android') {
- const granted = await PermissionsAndroid.request(
- PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
- {
- title: 'Microphone Permission',
- message: 'This app needs access to your microphone for speech recognition.',
- buttonNeutral: 'Ask Me Later',
- buttonNegative: 'Cancel',
- buttonPositive: 'OK',
- }
- );
- if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
- Alert.alert('Permission Denied', 'Microphone permission is required for speech recognition.');
- return;
- }
- }
-
- console.warn('[STT] Starting native recording...');
- const result = await NativeAudioModule.startRecording();
-
- recordingPathRef.current = result.path;
- recordingStartRef.current = Date.now();
- setIsRecording(true);
- setTranscription('');
- setRecordingDuration(0);
-
- // Poll for audio levels
- audioLevelIntervalRef.current = setInterval(async () => {
- try {
- const levelResult = await NativeAudioModule.getAudioLevel();
- setAudioLevel(levelResult.level || 0);
- setRecordingDuration(Date.now() - recordingStartRef.current);
- } catch (e) {
- // Ignore errors during polling
- }
- }, 100);
-
- console.warn('[STT] Recording started at:', result.path);
- } catch (error) {
- console.error('[STT] Recording error:', error);
- Alert.alert('Recording Error', `Failed to start recording: ${error}`);
- }
- };
-
- const stopRecordingAndTranscribe = async () => {
- try {
- // Clear audio level polling
- if (audioLevelIntervalRef.current) {
- clearInterval(audioLevelIntervalRef.current);
- audioLevelIntervalRef.current = null;
- }
-
- if (!NativeAudioModule) {
- throw new Error('NativeAudioModule not available');
- }
-
- console.warn('[STT] Stopping recording...');
- const result = await NativeAudioModule.stopRecording();
- setIsRecording(false);
- setAudioLevel(0);
- setIsTranscribing(true);
-
- // Get the base64 audio data directly from native module (bypasses RNFS sandbox issues)
- const audioBase64 = result.audioBase64;
- if (!audioBase64) {
- throw new Error('No audio data received from recording');
- }
-
- console.warn('[STT] Recording stopped, audio base64 length:', audioBase64.length, 'file size:', result.fileSize);
-
- if (result.fileSize < 1000) {
- throw new Error('Recording too short - please speak longer');
- }
-
- // Check if STT model is loaded
- const isModelLoaded = await RunAnywhere.isSTTModelLoaded();
- if (!isModelLoaded) {
- throw new Error('STT model not loaded. Please download and load the model first.');
- }
-
- // Transcribe using base64 audio data directly from native module
- console.warn('[STT] Starting transcription...');
- const transcribeResult = await RunAnywhere.transcribe(audioBase64, {
- sampleRate: 16000,
- language: 'en',
- });
-
- console.warn('[STT] Transcription result:', transcribeResult);
-
- if (transcribeResult.text) {
- setTranscription(transcribeResult.text);
- setTranscriptionHistory(prev => [transcribeResult.text, ...prev]);
- } else {
- setTranscription('(No speech detected)');
- }
-
- recordingPathRef.current = null;
- setIsTranscribing(false);
- } catch (error) {
- console.error('[STT] Transcription error:', error);
- const errorMessage = error instanceof Error ? error.message : String(error);
- setTranscription(`Error: ${errorMessage}`);
- Alert.alert('Transcription Error', errorMessage);
- setIsTranscribing(false);
- }
- };
-
- const handleClearHistory = () => {
- setTranscriptionHistory([]);
- setTranscription('');
- };
-
- const formatDuration = (ms: number): string => {
- const totalSeconds = Math.floor(ms / 1000);
- const minutes = Math.floor(totalSeconds / 60);
- const seconds = totalSeconds % 60;
- return `${minutes}:${seconds.toString().padStart(2, '0')}`;
- };
-
- if (!modelService.isSTTLoaded) {
- return (
-
- );
- }
-
- return (
-
-
- {/* Recording Area */}
-
- {isRecording ? (
- <>
-
-
- Listening...
-
-
- {formatDuration(recordingDuration)}
-
- >
- ) : isTranscribing ? (
- <>
-
- âŗ
-
- Transcribing...
- >
- ) : (
- <>
-
- đ¤
-
- Tap to Record
- On-device speech recognition (WAV 16kHz)
- >
- )}
-
-
- {/* Current Transcription */}
- {(transcription || isTranscribing) && (
-
-
- LATEST
-
-
- {isTranscribing ? 'Processing...' : transcription}
-
-
- )}
-
- {/* History */}
- {transcriptionHistory.length > 0 && (
-
-
- History
-
- Clear
-
-
- {transcriptionHistory.map((item, index) => (
-
- {item}
-
- ))}
-
- )}
-
-
- {/* Record Button */}
-
-
-
- {isRecording ? 'âš' : 'đ¤'}
-
- {isRecording ? 'Stop Recording' : 'Start Recording'}
-
-
-
-
-
- );
-};
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: AppColors.primaryDark,
- },
- scrollView: {
- flex: 1,
- },
- scrollContent: {
- padding: 24,
- },
- recordingArea: {
- padding: 32,
- backgroundColor: AppColors.surfaceCard,
- borderRadius: 24,
- borderWidth: 1,
- borderColor: AppColors.textMuted + '1A',
- alignItems: 'center',
- marginBottom: 24,
- },
- recordingActive: {
- borderColor: AppColors.accentViolet + '80',
- borderWidth: 2,
- shadowColor: AppColors.accentViolet,
- shadowOffset: { width: 0, height: 0 },
- shadowOpacity: 0.3,
- shadowRadius: 20,
- elevation: 8,
- },
- micContainer: {
- width: 100,
- height: 100,
- backgroundColor: AppColors.accentViolet + '20',
- borderRadius: 50,
- justifyContent: 'center',
- alignItems: 'center',
- marginBottom: 24,
- },
- micIcon: {
- fontSize: 48,
- },
- loadingContainer: {
- width: 80,
- height: 80,
- justifyContent: 'center',
- alignItems: 'center',
- marginBottom: 24,
- },
- loadingIcon: {
- fontSize: 48,
- },
- statusTitle: {
- fontSize: 20,
- fontWeight: '700',
- color: AppColors.textPrimary,
- marginBottom: 8,
- },
- statusSubtitle: {
- fontSize: 14,
- color: AppColors.textSecondary,
- },
- transcriptionCard: {
- padding: 20,
- backgroundColor: AppColors.surfaceCard,
- borderRadius: 16,
- borderWidth: 1,
- borderColor: AppColors.accentViolet + '40',
- marginBottom: 24,
- },
- badge: {
- alignSelf: 'flex-start',
- paddingHorizontal: 8,
- paddingVertical: 4,
- backgroundColor: AppColors.accentViolet + '33',
- borderRadius: 8,
- marginBottom: 12,
- },
- badgeText: {
- fontSize: 10,
- fontWeight: '700',
- color: AppColors.accentViolet,
- },
- transcriptionText: {
- fontSize: 15,
- color: AppColors.textPrimary,
- lineHeight: 22,
- },
- historySection: {
- marginBottom: 24,
- },
- historyHeader: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- marginBottom: 12,
- },
- historyTitle: {
- fontSize: 16,
- fontWeight: '600',
- color: AppColors.textMuted,
- },
- clearButton: {
- fontSize: 14,
- color: AppColors.accentViolet,
- },
- historyItem: {
- padding: 16,
- backgroundColor: AppColors.surfaceCard + '80',
- borderRadius: 12,
- borderWidth: 1,
- borderColor: AppColors.textMuted + '1A',
- marginBottom: 12,
- },
- historyText: {
- fontSize: 14,
- color: AppColors.textSecondary,
- lineHeight: 20,
- },
- buttonContainer: {
- padding: 24,
- backgroundColor: AppColors.surfaceCard + 'CC',
- borderTopWidth: 1,
- borderTopColor: AppColors.textMuted + '1A',
- },
- recordButton: {
- flexDirection: 'row',
- height: 72,
- borderRadius: 36,
- justifyContent: 'center',
- alignItems: 'center',
- gap: 12,
- elevation: 8,
- shadowColor: AppColors.accentViolet,
- shadowOffset: { width: 0, height: 8 },
- shadowOpacity: 0.4,
- shadowRadius: 20,
- },
- recordIcon: {
- fontSize: 28,
- },
- recordButtonText: {
- fontSize: 16,
- fontWeight: '700',
- color: '#FFFFFF',
- },
-});
diff --git a/src/screens/TextToSpeechScreen.tsx b/src/screens/TextToSpeechScreen.tsx
deleted file mode 100644
index 21c0588..0000000
--- a/src/screens/TextToSpeechScreen.tsx
+++ /dev/null
@@ -1,451 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import {
- View,
- Text,
- TextInput,
- TouchableOpacity,
- ScrollView,
- StyleSheet,
- NativeModules,
-} from 'react-native';
-import LinearGradient from 'react-native-linear-gradient';
-import RNFS from 'react-native-fs';
-import { RunAnywhere } from '@runanywhere/core';
-import { AppColors } from '../theme';
-import { useModelService } from '../services/ModelService';
-import { ModelLoaderWidget } from '../components';
-
-// Native Audio Module for better audio session management
-const { NativeAudioModule } = NativeModules;
-
-const SAMPLE_TEXTS = [
- 'Hello! Welcome to RunAnywhere. Experience the power of on-device AI.',
- 'The quick brown fox jumps over the lazy dog.',
- 'Technology is best when it brings people together.',
- 'Privacy is not something that I am merely entitled to, it is an absolute prerequisite.',
-];
-
-export const TextToSpeechScreen: React.FC = () => {
- const modelService = useModelService();
- const [text, setText] = useState('');
- const [isSynthesizing, setIsSynthesizing] = useState(false);
- const [isPlaying, setIsPlaying] = useState(false);
- const [speechRate, setSpeechRate] = useState(1.0);
- const [currentAudioPath, setCurrentAudioPath] = useState(null);
-
- // Cleanup on unmount
- useEffect(() => {
- return () => {
- if (NativeAudioModule && isPlaying) {
- NativeAudioModule.stopPlayback().catch(() => {});
- }
- };
- }, [isPlaying]);
-
- const synthesizeAndPlay = async () => {
- if (!text.trim()) {
- return;
- }
-
- setIsSynthesizing(true);
-
- try {
- // Per docs: https://docs.runanywhere.ai/react-native/tts/synthesize
- // result.audio contains base64-encoded float32 PCM
- // Using same config as sample app for consistent voice output
- const result = await RunAnywhere.synthesize(text, {
- voice: 'default',
- rate: speechRate,
- pitch: 1.0,
- volume: 1.0,
- });
-
- console.log(`[TTS] Synthesized: duration=${result.duration}s, sampleRate=${result.sampleRate}Hz, numSamples=${result.numSamples}`);
-
- // Use SDK's built-in WAV converter (same as sample app)
- const tempPath = await RunAnywhere.Audio.createWavFromPCMFloat32(
- result.audio,
- result.sampleRate || 22050
- );
-
- console.log(`[TTS] WAV file created: ${tempPath}`);
-
- setCurrentAudioPath(tempPath);
- setIsSynthesizing(false);
- setIsPlaying(true);
-
- // Play using native audio module
- if (NativeAudioModule) {
- try {
- const playResult = await NativeAudioModule.playAudio(tempPath);
- console.log(`[TTS] Playback started, duration: ${playResult.duration}s`);
-
- // Wait for playback to complete (approximate based on duration)
- setTimeout(() => {
- setIsPlaying(false);
- setCurrentAudioPath(null);
- // Clean up file
- RNFS.unlink(tempPath).catch(() => {});
- }, (result.duration + 0.5) * 1000);
- } catch (playError) {
- console.error('[TTS] Native playback error:', playError);
- setIsPlaying(false);
- }
- } else {
- console.error('[TTS] NativeAudioModule not available');
- setIsPlaying(false);
- }
- } catch (error) {
- console.error('[TTS] Error:', error);
- setIsSynthesizing(false);
- setIsPlaying(false);
- }
- };
-
- const stopPlayback = async () => {
- if (NativeAudioModule) {
- try {
- await NativeAudioModule.stopPlayback();
- } catch (e) {
- // Ignore
- }
- }
- setIsPlaying(false);
-
- // Clean up file
- if (currentAudioPath) {
- RNFS.unlink(currentAudioPath).catch(() => {});
- setCurrentAudioPath(null);
- }
- };
-
- if (!modelService.isTTSLoaded) {
- return (
-
- );
- }
-
- return (
-
-
- {/* Input Section */}
-
-
-
-
- đ {text.length} characters
-
- {text.length > 0 && (
- setText('')}>
- Clear
-
- )}
-
-
-
- {/* Controls */}
-
- Speech Rate
-
- đ
- {speechRate.toFixed(1)}x
- đ
-
-
- {[0.5, 0.75, 1.0, 1.5, 2.0].map((rate) => (
- setSpeechRate(rate)}
- style={[
- styles.rateButton,
- speechRate === rate && styles.rateButtonActive,
- ]}
- >
-
- {rate}x
-
-
- ))}
-
-
-
- {/* Playback Area */}
-
- {isPlaying ? (
- <>
-
- {[...Array(7)].map((_, i) => (
-
- ))}
-
- Playing...
- >
- ) : isSynthesizing ? (
- <>
- âŗ
- Synthesizing...
- >
- ) : (
- <>
- đ
- Tap to synthesize
- >
- )}
-
- {/* Play Button */}
-
-
-
- {isSynthesizing ? 'âŗ' : isPlaying ? 'âš' : 'âļī¸'}
-
-
-
-
-
- {/* Sample Texts */}
-
- Sample Texts
- {SAMPLE_TEXTS.map((sample, index) => (
- setText(sample)}
- style={styles.sampleItem}
- >
-
- {sample}
-
- â
-
- ))}
-
-
-
- );
-};
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: AppColors.primaryDark,
- },
- scrollView: {
- flex: 1,
- },
- scrollContent: {
- padding: 24,
- },
- inputCard: {
- backgroundColor: AppColors.surfaceCard,
- borderRadius: 20,
- borderWidth: 1,
- borderColor: AppColors.accentPink + '33',
- marginBottom: 24,
- overflow: 'hidden',
- },
- input: {
- padding: 20,
- fontSize: 15,
- color: AppColors.textPrimary,
- minHeight: 120,
- textAlignVertical: 'top',
- },
- inputFooter: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- padding: 12,
- paddingHorizontal: 16,
- backgroundColor: AppColors.primaryMid,
- },
- characterCount: {
- fontSize: 12,
- color: AppColors.textMuted,
- },
- clearText: {
- fontSize: 14,
- color: AppColors.accentPink,
- fontWeight: '600',
- },
- controlsCard: {
- padding: 20,
- backgroundColor: AppColors.surfaceCard,
- borderRadius: 16,
- borderWidth: 1,
- borderColor: AppColors.textMuted + '1A',
- marginBottom: 24,
- },
- controlLabel: {
- fontSize: 16,
- fontWeight: '600',
- color: AppColors.textPrimary,
- marginBottom: 16,
- },
- sliderContainer: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- marginBottom: 16,
- },
- sliderIcon: {
- fontSize: 20,
- },
- sliderValue: {
- fontSize: 18,
- fontWeight: '700',
- color: AppColors.accentPink,
- paddingHorizontal: 16,
- paddingVertical: 8,
- backgroundColor: AppColors.accentPink + '20',
- borderRadius: 12,
- },
- rateButtons: {
- flexDirection: 'row',
- gap: 8,
- },
- rateButton: {
- flex: 1,
- paddingVertical: 8,
- backgroundColor: AppColors.surfaceElevated,
- borderRadius: 8,
- alignItems: 'center',
- },
- rateButtonActive: {
- backgroundColor: AppColors.accentPink + '40',
- },
- rateButtonText: {
- fontSize: 14,
- color: AppColors.textSecondary,
- fontWeight: '600',
- },
- rateButtonTextActive: {
- color: AppColors.accentPink,
- },
- playbackArea: {
- padding: 24,
- backgroundColor: AppColors.surfaceCard,
- borderRadius: 20,
- borderWidth: 1,
- borderColor: AppColors.textMuted + '1A',
- alignItems: 'center',
- marginBottom: 32,
- },
- playbackActive: {
- borderColor: AppColors.accentPink + '80',
- borderWidth: 2,
- shadowColor: AppColors.accentPink,
- shadowOffset: { width: 0, height: 0 },
- shadowOpacity: 0.3,
- shadowRadius: 20,
- elevation: 8,
- },
- waveform: {
- flexDirection: 'row',
- height: 60,
- alignItems: 'center',
- justifyContent: 'center',
- gap: 6,
- marginBottom: 24,
- },
- waveBar: {
- width: 6,
- height: 40,
- backgroundColor: AppColors.accentPink,
- borderRadius: 3,
- },
- playbackIcon: {
- fontSize: 48,
- marginBottom: 16,
- },
- loadingIcon: {
- fontSize: 48,
- marginBottom: 16,
- },
- playbackStatus: {
- fontSize: 14,
- color: AppColors.textSecondary,
- marginBottom: 24,
- },
- playButtonWrapper: {
- marginTop: 8,
- },
- playButton: {
- width: 80,
- height: 80,
- borderRadius: 40,
- justifyContent: 'center',
- alignItems: 'center',
- elevation: 8,
- shadowColor: AppColors.accentPink,
- shadowOffset: { width: 0, height: 8 },
- shadowOpacity: 0.4,
- shadowRadius: 20,
- },
- playButtonIcon: {
- fontSize: 32,
- },
- samplesSection: {
- marginBottom: 24,
- },
- samplesTitle: {
- fontSize: 16,
- fontWeight: '600',
- color: AppColors.textMuted,
- marginBottom: 12,
- },
- sampleItem: {
- flexDirection: 'row',
- alignItems: 'center',
- padding: 16,
- backgroundColor: AppColors.surfaceCard + '80',
- borderRadius: 12,
- borderWidth: 1,
- borderColor: AppColors.textMuted + '1A',
- marginBottom: 12,
- },
- sampleText: {
- flex: 1,
- fontSize: 12,
- color: AppColors.textSecondary,
- lineHeight: 18,
- },
- sampleIcon: {
- fontSize: 20,
- color: AppColors.accentPink + '99',
- marginLeft: 8,
- },
-});
diff --git a/src/screens/ToolCallingScreen.tsx b/src/screens/ToolCallingScreen.tsx
deleted file mode 100644
index 562decc..0000000
--- a/src/screens/ToolCallingScreen.tsx
+++ /dev/null
@@ -1,616 +0,0 @@
-import React, { useState, useRef, useEffect } from 'react';
-import {
- View,
- Text,
- TextInput,
- TouchableOpacity,
- ScrollView,
- StyleSheet,
- KeyboardAvoidingView,
- Platform,
- ActivityIndicator,
-} from 'react-native';
-import LinearGradient from 'react-native-linear-gradient';
-import {
- RunAnywhere,
- ToolDefinition,
- ToolCall,
- ToolResult,
- ToolCallingResult,
-} from '@runanywhere/core';
-import { AppColors } from '../theme';
-import { useModelService } from '../services/ModelService';
-import { ModelLoaderWidget } from '../components';
-
-// âââ Tool Definitions ââââââââââââââââââââââââââââââââââââââââââââ
-
-const DEMO_TOOLS: ToolDefinition[] = [
- {
- name: 'get_weather',
- description: 'Get the current weather for a given city',
- parameters: [
- {
- name: 'city',
- type: 'string',
- description: 'The city name, e.g. "San Francisco"',
- required: true,
- },
- {
- name: 'unit',
- type: 'string',
- description: 'Temperature unit: "celsius" or "fahrenheit"',
- required: false,
- defaultValue: 'celsius',
- enum: ['celsius', 'fahrenheit'],
- },
- ],
- },
- {
- name: 'calculate',
- description: 'Perform a mathematical calculation',
- parameters: [
- {
- name: 'expression',
- type: 'string',
- description: 'A math expression to evaluate, e.g. "2 + 2"',
- required: true,
- },
- ],
- },
- {
- name: 'get_time',
- description: 'Get the current date and time for a timezone',
- parameters: [
- {
- name: 'timezone',
- type: 'string',
- description: 'IANA timezone, e.g. "America/New_York"',
- required: false,
- defaultValue: 'UTC',
- },
- ],
- },
-];
-
-// âââ Mock Tool Executors âââââââââââââââââââââââââââââââââââââââââ
-
-const mockWeather = async (args: Record) => {
- const city = (args.city as string) || 'Unknown';
- const unit = (args.unit as string) || 'celsius';
- const temp = Math.floor(Math.random() * 30) + 5;
- return {
- city,
- temperature: unit === 'fahrenheit' ? Math.round(temp * 1.8 + 32) : temp,
- unit,
- condition: ['Sunny', 'Cloudy', 'Rainy', 'Partly Cloudy'][Math.floor(Math.random() * 4)],
- humidity: Math.floor(Math.random() * 60) + 30,
- };
-};
-
-const mockCalculate = async (args: Record) => {
- const expr = (args.expression as string) || '0';
- try {
- // Simple safe eval for basic math
- const sanitized = expr.replace(/[^0-9+\-*/().% ]/g, '');
- const result = Function(`"use strict"; return (${sanitized})`)();
- return { expression: expr, result: Number(result) };
- } catch {
- return { expression: expr, error: 'Could not evaluate expression' };
- }
-};
-
-const mockGetTime = async (args: Record) => {
- const tz = (args.timezone as string) || 'UTC';
- try {
- const now = new Date().toLocaleString('en-US', { timeZone: tz });
- return { timezone: tz, datetime: now };
- } catch {
- return { timezone: tz, datetime: new Date().toISOString() };
- }
-};
-
-// âââ Log Entry Types âââââââââââââââââââââââââââââââââââââââââââââ
-
-type LogType = 'info' | 'prompt' | 'tool_call' | 'tool_result' | 'response' | 'error';
-
-interface LogEntry {
- id: number;
- type: LogType;
- title: string;
- detail?: string;
- timestamp: Date;
-}
-
-// âââ Screen Component ââââââââââââââââââââââââââââââââââââââââââââ
-
-export const ToolCallingScreen: React.FC = () => {
- const modelService = useModelService();
- const [inputText, setInputText] = useState('');
- const [isRunning, setIsRunning] = useState(false);
- const [logs, setLogs] = useState([]);
- const [toolsRegistered, setToolsRegistered] = useState(false);
- const scrollRef = useRef(null);
- const logIdRef = useRef(0);
-
- // Auto-scroll on new logs
- useEffect(() => {
- if (logs.length > 0) {
- setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 100);
- }
- }, [logs]);
-
- const addLog = (type: LogType, title: string, detail?: string) => {
- const id = Date.now() * 1000 + Math.floor(Math.random() * 1000);
- setLogs(prev => [
- ...prev,
- { id, type, title, detail, timestamp: new Date() },
- ]);
- };
-
- // âââ Register tools ââââââââââââââââââââââââââââââââââââââââââ
-
- const handleRegisterTools = () => {
- try {
- RunAnywhere.clearTools();
-
- RunAnywhere.registerTool(DEMO_TOOLS[0], mockWeather);
- RunAnywhere.registerTool(DEMO_TOOLS[1], mockCalculate);
- RunAnywhere.registerTool(DEMO_TOOLS[2], mockGetTime);
-
- setToolsRegistered(true);
- addLog('info', 'Tools Registered', `Registered ${DEMO_TOOLS.length} tools: ${DEMO_TOOLS.map(t => t.name).join(', ')}`);
- } catch (error) {
- addLog('error', 'Registration Failed', String(error));
- }
- };
-
- // âââ Run tool calling generation âââââââââââââââââââââââââââââ
-
- const handleGenerate = async () => {
- const prompt = inputText.trim();
- if (!prompt || isRunning) return;
-
- setInputText('');
- setIsRunning(true);
- addLog('prompt', 'User Prompt', prompt);
-
- try {
- const result: ToolCallingResult = await RunAnywhere.generateWithTools(prompt, {
- tools: DEMO_TOOLS,
- maxToolCalls: 3,
- autoExecute: true,
- temperature: 0.7,
- maxTokens: 512,
- });
-
- // Log tool calls
- if (result.toolCalls.length > 0) {
- for (let i = 0; i < result.toolCalls.length; i++) {
- const tc = result.toolCalls[i];
- addLog(
- 'tool_call',
- `Tool Call: ${tc.toolName}`,
- JSON.stringify(tc.arguments, null, 2),
- );
- if (result.toolResults[i]) {
- const tr = result.toolResults[i];
- addLog(
- 'tool_result',
- `Result: ${tr.toolName} (${tr.success ? 'success' : 'failed'})`,
- tr.success ? JSON.stringify(tr.result, null, 2) : tr.error,
- );
- }
- }
- } else {
- addLog('info', 'No Tool Calls', 'The model responded without calling any tools');
- }
-
- // Log final response
- addLog('response', 'Model Response', result.text || '(empty)');
- } catch (error) {
- addLog('error', 'Generation Failed', String(error));
- } finally {
- setIsRunning(false);
- }
- };
-
- // âââ Manual parse test âââââââââââââââââââââââââââââââââââââââ
-
- const handleParseSample = async () => {
- addLog('info', 'Parse Test', 'Testing parseToolCall with sample output...');
-
- const sampleOutput = `I'll check the weather for you.\n{"name": "get_weather", "arguments": {"city": "San Francisco"}}`;
-
- try {
- const parsed = await RunAnywhere.parseToolCall(sampleOutput);
- addLog(
- 'tool_call',
- 'Parsed Tool Call',
- parsed.toolCall
- ? `Tool: ${parsed.toolCall.toolName}\nArgs: ${JSON.stringify(parsed.toolCall.arguments, null, 2)}\nClean text: "${parsed.text}"`
- : `No tool call detected. Text: "${parsed.text}"`,
- );
- } catch (error) {
- addLog('error', 'Parse Failed', String(error));
- }
- };
-
- // âââ Render ââââââââââââââââââââââââââââââââââââââââââââââââââ
-
- if (!modelService.isLLMLoaded) {
- return (
-
- );
- }
-
- return (
-
- {/* Action Buttons */}
-
-
-
- {toolsRegistered ? 'Tools Ready' : 'Register Tools'}
-
-
-
-
- Parse Test
-
-
- setLogs([])}
- >
- Clear
-
-
-
- {/* Tool chips */}
-
- {DEMO_TOOLS.map(tool => (
-
- {tool.name}
-
- ))}
-
-
- {/* Log output */}
-
- {logs.length === 0 ? (
-
- đ
- Tool Calling Test
-
- Register tools, then ask the model to use them.{'\n'}
- Try: "What's the weather in Tokyo?" or "Calculate 42 * 17"
-
-
- ) : (
- logs.map(log => (
-
-
- {LOG_ICONS[log.type]}
- {log.title}
-
- {log.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
-
-
- {log.detail ? (
- {log.detail}
- ) : null}
-
- ))
- )}
- {isRunning && (
-
-
- Generating...
-
- )}
-
-
- {/* Suggestion chips */}
-
- {['What\'s the weather in Tokyo?', 'Calculate 123 * 456', 'What time is it in New York?'].map(s => (
- setInputText(s)}
- >
- {s}
-
- ))}
-
-
- {/* Input */}
-
-
-
-
-
- âļ
-
-
-
-
-
- );
-};
-
-// âââ Constants âââââââââââââââââââââââââââââââââââââââââââââââââ
-
-const LOG_ICONS: Record = {
- info: 'âšī¸',
- prompt: 'đŦ',
- tool_call: 'đ§',
- tool_result: 'đĻ',
- response: 'đ¤',
- error: 'â',
-};
-
-// âââ Styles ââââââââââââââââââââââââââââââââââââââââââââââââââââ
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: AppColors.primaryDark,
- },
-
- // Action bar
- actionBar: {
- flexDirection: 'row',
- padding: 12,
- gap: 8,
- },
- actionBtn: {
- flex: 1,
- paddingVertical: 10,
- borderRadius: 12,
- backgroundColor: AppColors.surfaceCard,
- borderWidth: 1,
- borderColor: AppColors.accentOrange + '40',
- alignItems: 'center',
- },
- actionBtnActive: {
- backgroundColor: AppColors.accentOrange + '20',
- borderColor: AppColors.accentOrange,
- },
- actionBtnText: {
- fontSize: 13,
- fontWeight: '600',
- color: AppColors.accentOrange,
- },
- actionBtnClear: {
- paddingVertical: 10,
- paddingHorizontal: 16,
- borderRadius: 12,
- backgroundColor: AppColors.surfaceCard,
- borderWidth: 1,
- borderColor: AppColors.textMuted + '40',
- alignItems: 'center',
- },
- actionBtnClearText: {
- fontSize: 13,
- fontWeight: '600',
- color: AppColors.textMuted,
- },
-
- // Tool chips
- toolChips: {
- flexDirection: 'row',
- paddingHorizontal: 12,
- paddingBottom: 8,
- gap: 6,
- },
- toolChip: {
- paddingHorizontal: 10,
- paddingVertical: 4,
- backgroundColor: AppColors.surfaceElevated,
- borderRadius: 8,
- },
- toolChipText: {
- fontSize: 11,
- fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
- color: AppColors.textSecondary,
- },
-
- // Log area
- logArea: {
- flex: 1,
- },
- logContent: {
- padding: 12,
- paddingBottom: 8,
- },
-
- // Empty state
- emptyState: {
- flex: 1,
- justifyContent: 'center',
- alignItems: 'center',
- paddingVertical: 80,
- },
- emptyIcon: {
- fontSize: 56,
- marginBottom: 16,
- },
- emptyTitle: {
- fontSize: 22,
- fontWeight: '700',
- color: AppColors.textPrimary,
- marginBottom: 8,
- },
- emptySubtitle: {
- fontSize: 13,
- color: AppColors.textSecondary,
- textAlign: 'center',
- lineHeight: 20,
- paddingHorizontal: 32,
- },
-
- // Log entries
- logEntry: {
- marginBottom: 8,
- padding: 12,
- borderRadius: 12,
- borderWidth: 1,
- },
- logHeader: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 6,
- },
- logIcon: {
- fontSize: 14,
- },
- logTitle: {
- flex: 1,
- fontSize: 13,
- fontWeight: '600',
- color: AppColors.textPrimary,
- },
- logTime: {
- fontSize: 10,
- color: AppColors.textMuted,
- fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
- },
- logDetail: {
- marginTop: 6,
- fontSize: 12,
- color: AppColors.textSecondary,
- fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
- lineHeight: 18,
- },
-
- // Log type-specific colors
- log_info: {
- backgroundColor: AppColors.info + '10',
- borderColor: AppColors.info + '30',
- },
- log_prompt: {
- backgroundColor: AppColors.accentCyan + '10',
- borderColor: AppColors.accentCyan + '30',
- },
- log_tool_call: {
- backgroundColor: AppColors.accentOrange + '10',
- borderColor: AppColors.accentOrange + '30',
- },
- log_tool_result: {
- backgroundColor: AppColors.accentGreen + '10',
- borderColor: AppColors.accentGreen + '30',
- },
- log_response: {
- backgroundColor: AppColors.accentViolet + '10',
- borderColor: AppColors.accentViolet + '30',
- },
- log_error: {
- backgroundColor: AppColors.error + '10',
- borderColor: AppColors.error + '30',
- },
-
- // Loading
- loadingRow: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 8,
- paddingVertical: 12,
- justifyContent: 'center',
- },
- loadingText: {
- fontSize: 13,
- color: AppColors.accentOrange,
- },
-
- // Suggestions
- suggestions: {
- flexDirection: 'row',
- paddingHorizontal: 12,
- paddingBottom: 8,
- gap: 6,
- },
- suggestionChip: {
- flex: 1,
- paddingHorizontal: 8,
- paddingVertical: 6,
- backgroundColor: AppColors.surfaceCard,
- borderRadius: 10,
- borderWidth: 1,
- borderColor: AppColors.accentOrange + '30',
- },
- suggestionText: {
- fontSize: 10,
- color: AppColors.textSecondary,
- textAlign: 'center',
- },
-
- // Input
- inputContainer: {
- padding: 12,
- backgroundColor: AppColors.surfaceCard + 'CC',
- borderTopWidth: 1,
- borderTopColor: AppColors.textMuted + '1A',
- },
- inputWrapper: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 10,
- },
- input: {
- flex: 1,
- backgroundColor: AppColors.primaryMid,
- borderRadius: 20,
- paddingHorizontal: 16,
- paddingVertical: 10,
- fontSize: 14,
- color: AppColors.textPrimary,
- maxHeight: 80,
- },
- sendButton: {
- width: 44,
- height: 44,
- borderRadius: 22,
- justifyContent: 'center',
- alignItems: 'center',
- },
- sendButtonDisabled: {
- opacity: 0.4,
- },
- sendIcon: {
- fontSize: 18,
- color: '#FFFFFF',
- },
-});
diff --git a/src/screens/VoicePipelineScreen.tsx b/src/screens/VoicePipelineScreen.tsx
deleted file mode 100644
index 5a5f1b5..0000000
--- a/src/screens/VoicePipelineScreen.tsx
+++ /dev/null
@@ -1,606 +0,0 @@
-import React, { useState, useRef, useCallback } from 'react';
-import {
- View,
- Text,
- TouchableOpacity,
- ScrollView,
- StyleSheet,
- Platform,
- NativeModules,
-} from 'react-native';
-import LinearGradient from 'react-native-linear-gradient';
-import RNFS from 'react-native-fs';
-import { RunAnywhere, VoiceSessionEvent, VoiceSessionHandle } from '@runanywhere/core';
-import { AppColors } from '../theme';
-import { useModelService } from '../services/ModelService';
-import { ModelLoaderWidget, AudioVisualizer } from '../components';
-
-// Conditionally import Sound - disabled on iOS via react-native.config.js
-let Sound: any = null;
-if (Platform.OS === 'android') {
- try {
- Sound = require('react-native-sound').default;
- } catch (e) {
- console.log('react-native-sound not available');
- }
-}
-
-// iOS uses NativeAudioModule
-const { NativeAudioModule } = NativeModules;
-
-interface ConversationMessage {
- role: 'user' | 'assistant';
- text: string;
- timestamp: Date;
-}
-
-// Model IDs - must match those registered in ModelService
-const MODEL_IDS = {
- llm: 'lfm2-350m-q8_0',
- stt: 'sherpa-onnx-whisper-tiny.en',
- tts: 'vits-piper-en_US-lessac-medium',
-};
-
-export const VoicePipelineScreen: React.FC = () => {
- const modelService = useModelService();
- const [isActive, setIsActive] = useState(false);
- const [status, setStatus] = useState('Ready');
- const [conversation, setConversation] = useState([]);
- const [audioLevel, setAudioLevel] = useState(0);
-
- // Refs for session and audio
- const sessionRef = useRef(null);
- const currentSoundRef = useRef(null);
- const isPlayingRef = useRef(false);
-
- // Handle voice session events per docs:
- // https://docs.runanywhere.ai/react-native/voice-agent#voicesessionevent
- const handleVoiceEvent = useCallback((event: VoiceSessionEvent) => {
- switch (event.type) {
- case 'sessionStarted':
- setStatus('Listening...');
- setAudioLevel(0.2);
- break;
-
- case 'listeningStarted':
- setStatus('Listening...');
- setAudioLevel(0.3);
- break;
-
- case 'speechDetected':
- setStatus('Hearing you...');
- setAudioLevel(0.7);
- break;
-
- case 'speechEnded':
- setAudioLevel(0.1);
- break;
-
- case 'transcribing':
- setStatus('Processing speech...');
- setAudioLevel(0.4);
- break;
-
- case 'transcriptionComplete':
- if (event.data?.transcript) {
- const userMessage: ConversationMessage = {
- role: 'user',
- text: event.data.transcript,
- timestamp: new Date(),
- };
- setConversation(prev => [...prev, userMessage]);
- }
- setStatus('Thinking...');
- setAudioLevel(0.5);
- break;
-
- case 'generating':
- setStatus('Generating response...');
- setAudioLevel(0.5);
- break;
-
- case 'generationComplete':
- if (event.data?.response) {
- const assistantMessage: ConversationMessage = {
- role: 'assistant',
- text: event.data.response,
- timestamp: new Date(),
- };
- setConversation(prev => [...prev, assistantMessage]);
- }
- setStatus('Synthesizing...');
- setAudioLevel(0.6);
- break;
-
- case 'synthesizing':
- setStatus('Preparing voice...');
- break;
-
- case 'synthesisComplete':
- setStatus('Speaking...');
- // Play audio if provided
- if (event.data?.audio) {
- playResponseAudio(event.data.audio);
- }
- break;
-
- case 'speaking':
- setStatus('Speaking...');
- setAudioLevel(0.8);
- break;
-
- case 'turnComplete':
- setStatus('Listening...');
- setAudioLevel(0.3);
- break;
-
- case 'error':
- setStatus(`Error: ${event.data?.error || 'Unknown error'}`);
- setAudioLevel(0);
- console.error('Voice session error:', event.data?.error);
- break;
- }
- }, []);
-
- // Play synthesized audio response - platform-specific
- const playResponseAudio = async (base64Audio: string) => {
- try {
- if (Platform.OS === 'ios' && NativeAudioModule) {
- // iOS: Use NativeAudioModule
- isPlayingRef.current = true;
- setAudioLevel(0.8);
- await NativeAudioModule.playAudioBase64(base64Audio, 22050);
- isPlayingRef.current = false;
- setAudioLevel(0.3);
- } else if (Platform.OS === 'android' && Sound) {
- // Android: Use react-native-sound
- const wavData = createWavFromBase64Float32(base64Audio, 22050);
- const tempPath = `${RNFS.TemporaryDirectoryPath}/voice_response_${Date.now()}.wav`;
- await RNFS.writeFile(tempPath, wavData, 'base64');
-
- const sound = new Sound(tempPath, '', (error: any) => {
- if (error) {
- console.error('Failed to load sound:', error);
- return;
- }
-
- currentSoundRef.current = sound;
- setAudioLevel(0.8);
-
- sound.play((success: boolean) => {
- sound.release();
- currentSoundRef.current = null;
- setAudioLevel(0.3);
- });
- });
- } else {
- console.warn('No audio playback module available');
- }
- } catch (error) {
- console.error('Error playing audio:', error);
- isPlayingRef.current = false;
- setAudioLevel(0.3);
- }
- };
-
- // Convert base64 float32 PCM to WAV format
- const createWavFromBase64Float32 = (base64Audio: string, sampleRate: number): string => {
- const binaryStr = atob(base64Audio);
- const bytes = new Uint8Array(binaryStr.length);
- for (let i = 0; i < binaryStr.length; i++) {
- bytes[i] = binaryStr.charCodeAt(i);
- }
- const float32Samples = new Float32Array(bytes.buffer);
- const numSamples = float32Samples.length;
-
- const wavBuffer = new ArrayBuffer(44 + numSamples * 2);
- const view = new DataView(wavBuffer);
-
- // WAV header
- const writeString = (offset: number, str: string) => {
- for (let i = 0; i < str.length; i++) {
- view.setUint8(offset + i, str.charCodeAt(i));
- }
- };
-
- writeString(0, 'RIFF');
- view.setUint32(4, 36 + numSamples * 2, true);
- writeString(8, 'WAVE');
- writeString(12, 'fmt ');
- view.setUint32(16, 16, true);
- view.setUint16(20, 1, true);
- view.setUint16(22, 1, true);
- view.setUint32(24, sampleRate, true);
- view.setUint32(28, sampleRate * 2, true);
- view.setUint16(32, 2, true);
- view.setUint16(34, 16, true);
- writeString(36, 'data');
- view.setUint32(40, numSamples * 2, true);
-
- let offset = 44;
- for (let i = 0; i < float32Samples.length; i++) {
- const s = Math.max(-1, Math.min(1, float32Samples[i]));
- view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
- offset += 2;
- }
-
- const uint8Array = new Uint8Array(wavBuffer);
- let result = '';
- for (let i = 0; i < uint8Array.length; i++) {
- result += String.fromCharCode(uint8Array[i]);
- }
- return btoa(result);
- };
-
- // Start voice session per docs:
- // https://docs.runanywhere.ai/react-native/voice-agent#startvoicesession
- const startVoiceAgent = async () => {
- setIsActive(true);
- setStatus('Starting...');
-
- try {
- // Per docs: Use startVoiceSession with VoiceSessionConfig and callback
- sessionRef.current = await RunAnywhere.startVoiceSession(
- {
- agentConfig: {
- llmModelId: MODEL_IDS.llm,
- sttModelId: MODEL_IDS.stt,
- ttsModelId: MODEL_IDS.tts,
- systemPrompt: 'You are a helpful, friendly voice assistant. Keep your responses brief and conversational.',
- generationOptions: {
- maxTokens: 150,
- temperature: 0.7,
- },
- },
- enableVAD: true,
- vadSensitivity: 0.5,
- speechTimeout: 3000, // 3 seconds timeout for speech
- },
- handleVoiceEvent
- );
- } catch (error) {
- console.error('Voice agent error:', error);
- setStatus(`Error: ${error}`);
- setIsActive(false);
- }
- };
-
- const stopVoiceAgent = async () => {
- try {
- // Stop any playing audio - platform-specific
- if (Platform.OS === 'ios' && isPlayingRef.current && NativeAudioModule) {
- await NativeAudioModule.stopPlayback();
- isPlayingRef.current = false;
- } else if (currentSoundRef.current) {
- currentSoundRef.current.stop(() => {
- currentSoundRef.current?.release();
- currentSoundRef.current = null;
- });
- }
-
- // Stop the voice session
- if (sessionRef.current) {
- await sessionRef.current.stop();
- sessionRef.current = null;
- }
-
- setIsActive(false);
- setStatus('Ready');
- setAudioLevel(0);
- } catch (error) {
- console.error('Stop voice agent error:', error);
- }
- };
-
- const clearConversation = () => {
- setConversation([]);
- };
-
- if (!modelService.isVoiceAgentReady) {
- return (
-
- );
- }
-
- return (
-
-
- {/* Status Area */}
-
- {isActive ? (
- <>
-
-
- {status}
-
-
- Voice agent is running
-
- >
- ) : (
- <>
-
- â¨
-
- Voice Agent
-
- Full speech-to-speech AI conversation
-
- >
- )}
-
-
- {/* Conversation */}
- {conversation.length > 0 && (
-
-
- Conversation
-
- Clear
-
-
- {conversation.map((message, index) => (
-
-
-
- {message.role === 'user' ? 'đ¤' : 'đ¤'}
-
-
- {message.role === 'user' ? 'You' : 'Assistant'}
-
-
- {message.text}
-
- ))}
-
- )}
-
- {/* Pipeline Info */}
- {!isActive && conversation.length === 0 && (
-
- How it works:
-
- 1ī¸âŖ
- Voice Activity Detection (VAD) listens for speech
-
-
- 2ī¸âŖ
- Speech is transcribed (STT with Whisper)
-
-
- 3ī¸âŖ
- AI generates response (LLM with SmolLM2)
-
-
- 4ī¸âŖ
- Response is spoken (TTS with Piper)
-
-
- )}
-
-
- {/* Control Button */}
-
-
-
-
- {isActive ? 'âš' : 'â¨'}
-
-
- {isActive ? 'Stop Agent' : 'Start Voice Agent'}
-
-
-
-
-
- );
-};
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: AppColors.primaryDark,
- },
- scrollView: {
- flex: 1,
- },
- scrollContent: {
- padding: 24,
- },
- statusArea: {
- padding: 32,
- backgroundColor: AppColors.surfaceCard,
- borderRadius: 24,
- borderWidth: 1,
- borderColor: AppColors.textMuted + '1A',
- alignItems: 'center',
- marginBottom: 24,
- },
- statusActive: {
- borderColor: AppColors.accentGreen + '80',
- borderWidth: 2,
- shadowColor: AppColors.accentGreen,
- shadowOffset: { width: 0, height: 0 },
- shadowOpacity: 0.3,
- shadowRadius: 20,
- elevation: 8,
- },
- agentIconContainer: {
- width: 100,
- height: 100,
- backgroundColor: AppColors.accentGreen + '20',
- borderRadius: 50,
- justifyContent: 'center',
- alignItems: 'center',
- marginBottom: 24,
- },
- agentIcon: {
- fontSize: 48,
- },
- statusText: {
- fontSize: 20,
- fontWeight: '700',
- color: AppColors.textPrimary,
- marginBottom: 8,
- },
- statusSubtitle: {
- fontSize: 14,
- color: AppColors.textSecondary,
- },
- conversationSection: {
- marginBottom: 24,
- },
- conversationHeader: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- marginBottom: 16,
- },
- conversationTitle: {
- fontSize: 18,
- fontWeight: '700',
- color: AppColors.textPrimary,
- },
- clearButton: {
- fontSize: 14,
- color: AppColors.accentGreen,
- fontWeight: '600',
- },
- messageCard: {
- padding: 16,
- borderRadius: 16,
- marginBottom: 12,
- borderWidth: 1,
- },
- userMessage: {
- backgroundColor: AppColors.accentCyan + '20',
- borderColor: AppColors.accentCyan + '40',
- alignSelf: 'flex-end',
- maxWidth: '85%',
- },
- assistantMessage: {
- backgroundColor: AppColors.surfaceCard,
- borderColor: AppColors.textMuted + '20',
- alignSelf: 'flex-start',
- maxWidth: '85%',
- },
- messageHeader: {
- flexDirection: 'row',
- alignItems: 'center',
- marginBottom: 8,
- },
- roleIcon: {
- fontSize: 18,
- marginRight: 8,
- },
- roleText: {
- fontSize: 12,
- fontWeight: '600',
- color: AppColors.textSecondary,
- textTransform: 'uppercase',
- },
- messageText: {
- fontSize: 14,
- color: AppColors.textPrimary,
- lineHeight: 20,
- },
- infoCard: {
- padding: 20,
- backgroundColor: AppColors.surfaceCard + '80',
- borderRadius: 16,
- borderWidth: 1,
- borderColor: AppColors.textMuted + '1A',
- },
- infoTitle: {
- fontSize: 16,
- fontWeight: '700',
- color: AppColors.textPrimary,
- marginBottom: 16,
- },
- infoStep: {
- flexDirection: 'row',
- alignItems: 'center',
- marginBottom: 12,
- },
- stepNumber: {
- fontSize: 20,
- marginRight: 12,
- },
- stepText: {
- fontSize: 14,
- color: AppColors.textSecondary,
- flex: 1,
- },
- buttonContainer: {
- padding: 24,
- backgroundColor: AppColors.surfaceCard + 'CC',
- borderTopWidth: 1,
- borderTopColor: AppColors.textMuted + '1A',
- },
- controlButton: {
- flexDirection: 'row',
- height: 72,
- borderRadius: 36,
- justifyContent: 'center',
- alignItems: 'center',
- gap: 12,
- elevation: 8,
- shadowColor: AppColors.accentGreen,
- shadowOffset: { width: 0, height: 8 },
- shadowOpacity: 0.4,
- shadowRadius: 20,
- },
- controlIcon: {
- fontSize: 28,
- },
- controlButtonText: {
- fontSize: 16,
- fontWeight: '700',
- color: '#FFFFFF',
- },
-});
diff --git a/src/screens/index.ts b/src/screens/index.ts
index 27b3c8c..e70edd7 100644
--- a/src/screens/index.ts
+++ b/src/screens/index.ts
@@ -1,6 +1,9 @@
-export * from './HomeScreen';
-export * from './ChatScreen';
-export * from './ToolCallingScreen';
-export * from './SpeechToTextScreen';
-export * from './TextToSpeechScreen';
-export * from './VoicePipelineScreen';
+export { HomeScreen } from './HomeScreen';
+export { LiveSessionScreen } from './LiveSessionScreen';
+export { InsightsScreen } from './InsightsScreen';
+export { SettingsScreen } from './SettingsScreen';
+export { DisclaimerScreen } from './DisclaimerScreen';
+export { OutcomeReplayScreen } from './OutcomeReplayScreen';
+
+export { PreSessionFormScreen } from './PreSessionFormScreen';
+export { PreSessionStrategyScreen } from './PreSessionStrategyScreen';
diff --git a/src/services/CounterStrategyEngine.ts b/src/services/CounterStrategyEngine.ts
new file mode 100644
index 0000000..1c0dbf8
--- /dev/null
+++ b/src/services/CounterStrategyEngine.ts
@@ -0,0 +1,220 @@
+/**
+ * đ PRIVACY NOTICE
+ * All counter-strategy generation runs locally on device.
+ * No data leaves this device. No cloud APIs. No LLM calls.
+ * Pure rule-based mapping from detected tactics to counter-strategies.
+ */
+
+import { NegotiationPattern, CounterStrategy } from '../types/session';
+import { getPatternDefinition } from '../ai/patternLibrary';
+
+/**
+ * Counter strategy definition for each tactic
+ */
+interface CounterStrategyDefinition {
+ suggestions: string[];
+ explanation: string;
+}
+
+/**
+ * Complete rule-based mapping: tactic â counter-strategies
+ */
+const COUNTER_STRATEGY_MAP: Record = {
+ [NegotiationPattern.ANCHORING]: {
+ suggestions: [
+ 'Ask for a detailed cost breakdown',
+ 'Present your own alternative anchor point',
+ 'Request comparison data from multiple sources',
+ ],
+ explanation:
+ 'Anchoring attempts to set a psychological reference point. Counter by introducing your own data-backed reference or questioning their basis.',
+ },
+
+ [NegotiationPattern.BUDGET_OBJECTION]: {
+ suggestions: [
+ 'Ask about budget flexibility and approval thresholds',
+ 'Break pricing into smaller phases or milestones',
+ 'Highlight ROI and value instead of focusing on cost',
+ ],
+ explanation:
+ 'Budget objection often masks value hesitation rather than a real constraint. Shift the conversation from cost to return on investment.',
+ },
+
+ [NegotiationPattern.AUTHORITY_PRESSURE]: {
+ suggestions: [
+ 'Ask for the specific decision criteria being used',
+ 'Suggest a joint review with all stakeholders',
+ 'Delay final commitment until decision-maker is present',
+ ],
+ explanation:
+ 'Authority pressure reduces your negotiation space by introducing an unseen decision-maker. Get access to the real authority.',
+ },
+
+ [NegotiationPattern.TIME_PRESSURE]: {
+ suggestions: [
+ 'Ask if the deadline is truly final or flexible',
+ 'Introduce a new variable to reset the timeline',
+ 'Propose a pause â revisit with fresh perspective',
+ ],
+ explanation:
+ 'Urgency framing creates artificial pressure to force quick decisions. Verify the deadline and resist rushing critical commitments.',
+ },
+
+ [NegotiationPattern.DEFLECTION]: {
+ suggestions: [
+ 'Pin down specific concerns driving the hesitation',
+ 'Set a concrete follow-up date and time right now',
+ 'Ask what information would help them decide today',
+ ],
+ explanation:
+ 'Deflection delays decisions without surfacing real objections. Gently identify what\'s actually holding them back.',
+ },
+
+ [NegotiationPattern.POSITIVE_SIGNAL]: {
+ suggestions: [
+ 'Capitalize on momentum â move toward commitment',
+ 'Summarize agreed points and lock them in writing',
+ 'Ask about next steps while enthusiasm is high',
+ ],
+ explanation:
+ 'Positive signals indicate openness. Strike while the iron is hot â clarify terms and advance toward agreement.',
+ },
+
+ [NegotiationPattern.NEGATIVE_SIGNAL]: {
+ suggestions: [
+ 'Probe for the specific concern behind the negativity',
+ 'Acknowledge their worry and provide concrete evidence',
+ 'Offer an alternative approach that addresses the issue',
+ ],
+ explanation:
+ 'Negative signals reveal underlying objections. Address them directly with empathy and data rather than ignoring them.',
+ },
+
+ [NegotiationPattern.COMMITMENT_LANGUAGE]: {
+ suggestions: [
+ 'Document the agreement immediately in writing',
+ 'Clarify all remaining terms before finalizing',
+ 'Confirm timeline and deliverables for next steps',
+ ],
+ explanation:
+ 'Commitment language signals readiness to close. Ensure all details are clear and get confirmation in writing.',
+ },
+
+ [NegotiationPattern.STRENGTH_SIGNAL]: {
+ suggestions: [
+ 'Leverage this momentum to position yourself firmly',
+ 'Directly tie this achievement to their current needs',
+ 'Use this high-value moment to pivot to compensation framing',
+ ],
+ explanation:
+ 'Strength signals display your leverage points. Use them to establish value before discussing terms.',
+ },
+};
+
+/**
+ * Cooldown tracker â stores last trigger timestamp per tactic
+ */
+const cooldownTracker: Map = new Map();
+
+/**
+ * Default cooldown period in milliseconds (10 seconds)
+ */
+const COOLDOWN_MS = 10_000;
+
+/**
+ * Default confidence threshold (70%)
+ */
+const CONFIDENCE_THRESHOLD = 70;
+
+/**
+ * Generate counter-strategies for a detected tactic.
+ *
+ * Returns null if:
+ * - Confidence is below threshold
+ * - Same tactic was triggered within cooldown period
+ *
+ * @param tactic - The detected NegotiationPattern
+ * @param confidence - Confidence score (0-100)
+ * @param cooldownMs - Optional custom cooldown in ms (default: 10000)
+ * @param threshold - Optional custom confidence threshold (default: 70)
+ */
+export const generateCounterStrategies = (
+ tactic: NegotiationPattern,
+ confidence: number,
+ cooldownMs: number = COOLDOWN_MS,
+ threshold: number = CONFIDENCE_THRESHOLD,
+): CounterStrategy | null => {
+ console.log('[CounterStrategyEngine] đ¯ generateCounterStrategies() called');
+ console.log('[CounterStrategyEngine] đ Tactic:', tactic);
+ console.log('[CounterStrategyEngine] đ Confidence:', confidence);
+
+ // 1. Check confidence threshold
+ if (confidence < threshold) {
+ console.log(
+ `[CounterStrategyEngine] â Confidence ${confidence}% below threshold ${threshold}%`,
+ );
+ return null;
+ }
+
+ // 2. Check cooldown
+ const now = Date.now();
+ const lastTriggered = cooldownTracker.get(tactic);
+
+ if (lastTriggered && now - lastTriggered < cooldownMs) {
+ const remaining = Math.round((cooldownMs - (now - lastTriggered)) / 1000);
+ console.log(
+ `[CounterStrategyEngine] âŗ Cooldown active for "${tactic}" â ${remaining}s remaining`,
+ );
+ return null;
+ }
+
+ // 3. Look up counter-strategy
+ const definition = COUNTER_STRATEGY_MAP[tactic];
+
+ if (!definition) {
+ console.log(`[CounterStrategyEngine] â No counter-strategy for tactic: ${tactic}`);
+ return null;
+ }
+
+ // 4. Get display name from pattern library
+ const patternDef = getPatternDefinition(tactic);
+
+ // 5. Update cooldown tracker
+ cooldownTracker.set(tactic, now);
+ console.log(`[CounterStrategyEngine] â
Counter-strategy generated for "${tactic}"`);
+
+ return {
+ tactic,
+ tacticDisplayName: patternDef.displayName,
+ confidence,
+ suggestions: definition.suggestions,
+ explanation: definition.explanation,
+ timestamp: now,
+ };
+};
+
+/**
+ * Reset cooldown for a specific tactic (for testing)
+ */
+export const resetCooldown = (tactic: NegotiationPattern): void => {
+ cooldownTracker.delete(tactic);
+};
+
+/**
+ * Reset all cooldowns (for testing or session reset)
+ */
+export const resetAllCooldowns = (): void => {
+ cooldownTracker.clear();
+};
+
+/**
+ * Check if a tactic is currently on cooldown
+ */
+export const isOnCooldown = (
+ tactic: NegotiationPattern,
+ cooldownMs: number = COOLDOWN_MS,
+): boolean => {
+ const lastTriggered = cooldownTracker.get(tactic);
+ if (!lastTriggered) return false;
+ return Date.now() - lastTriggered < cooldownMs;
+};
diff --git a/src/services/LocalStorageService.ts b/src/services/LocalStorageService.ts
new file mode 100644
index 0000000..fda7fbf
--- /dev/null
+++ b/src/services/LocalStorageService.ts
@@ -0,0 +1,320 @@
+/**
+ * đ PRIVACY NOTICE
+ * All data is stored locally on device using AsyncStorage.
+ * No cloud sync. No external storage. No data leaves this device.
+ */
+
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import {
+ Session,
+ AppSettings,
+ SessionStats,
+ NegotiationMode,
+ NegotiationPattern,
+} from '../types/session';
+
+// Storage keys
+const KEYS = {
+ SESSIONS: '@latent:sessions',
+ SESSION_PREFIX: '@latent:session:',
+ SETTINGS: '@latent:settings',
+ STATS: '@latent:stats',
+};
+
+/**
+ * Default app settings
+ */
+const DEFAULT_SETTINGS: AppSettings = {
+ defaultMode: NegotiationMode.SALES,
+ patternSensitivity: 1.0,
+ enableAutoSave: true,
+ autoSaveInterval: 45000, // 45 seconds as requested
+ enableHapticFeedback: true,
+ enableSuggestionNotifications: true,
+};
+
+/**
+ * LocalStorageService - Handles all AsyncStorage operations
+ */
+export class LocalStorageService {
+ /**
+ * Save a session to storage
+ */
+ static async saveSession(session: Session): Promise {
+ try {
+ const sessionKey = `${KEYS.SESSION_PREFIX}${session.id}`;
+ await AsyncStorage.setItem(sessionKey, JSON.stringify(session));
+
+ // Update session list
+ const sessionIds = await this.getAllSessionIds();
+ if (!sessionIds.includes(session.id)) {
+ sessionIds.push(session.id);
+ await AsyncStorage.setItem(KEYS.SESSIONS, JSON.stringify(sessionIds));
+ }
+
+ console.log('[LocalStorage] Session saved:', session.id);
+ } catch (error) {
+ console.error('[LocalStorage] Error saving session:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Get a session by ID
+ */
+ static async getSession(sessionId: string): Promise {
+ try {
+ const sessionKey = `${KEYS.SESSION_PREFIX}${sessionId}`;
+ const data = await AsyncStorage.getItem(sessionKey);
+ return data ? JSON.parse(data) : null;
+ } catch (error) {
+ console.error('[LocalStorage] Error getting session:', error);
+ return null;
+ }
+ }
+
+ /**
+ * Get all session IDs
+ */
+ static async getAllSessionIds(): Promise {
+ try {
+ const data = await AsyncStorage.getItem(KEYS.SESSIONS);
+ return data ? JSON.parse(data) : [];
+ } catch (error) {
+ console.error('[LocalStorage] Error getting session IDs:', error);
+ return [];
+ }
+ }
+
+ /**
+ * Get all sessions (sorted by timestamp, newest first)
+ */
+ static async getAllSessions(): Promise {
+ try {
+ const sessionIds = await this.getAllSessionIds();
+ const sessions: Session[] = [];
+
+ for (const id of sessionIds) {
+ const session = await this.getSession(id);
+ if (session) {
+ sessions.push(session);
+ }
+ }
+
+ // Sort by timestamp (newest first)
+ return sessions.sort((a, b) => b.timestamp - a.timestamp);
+ } catch (error) {
+ console.error('[LocalStorage] Error getting all sessions:', error);
+ return [];
+ }
+ }
+
+ /**
+ * Delete a session
+ */
+ static async deleteSession(sessionId: string): Promise {
+ try {
+ const sessionKey = `${KEYS.SESSION_PREFIX}${sessionId}`;
+ await AsyncStorage.removeItem(sessionKey);
+
+ // Update session list
+ const sessionIds = await this.getAllSessionIds();
+ const updatedIds = sessionIds.filter((id) => id !== sessionId);
+ await AsyncStorage.setItem(KEYS.SESSIONS, JSON.stringify(updatedIds));
+
+ console.log('[LocalStorage] Session deleted:', sessionId);
+ } catch (error) {
+ console.error('[LocalStorage] Error deleting session:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Delete all sessions
+ */
+ static async deleteAllSessions(): Promise {
+ try {
+ const sessionIds = await this.getAllSessionIds();
+
+ // Delete each session
+ for (const id of sessionIds) {
+ const sessionKey = `${KEYS.SESSION_PREFIX}${id}`;
+ await AsyncStorage.removeItem(sessionKey);
+ }
+
+ // Clear session list
+ await AsyncStorage.setItem(KEYS.SESSIONS, JSON.stringify([]));
+
+ console.log('[LocalStorage] All sessions deleted');
+ } catch (error) {
+ console.error('[LocalStorage] Error deleting all sessions:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Get app settings
+ */
+ static async getSettings(): Promise {
+ try {
+ const data = await AsyncStorage.getItem(KEYS.SETTINGS);
+ return data ? { ...DEFAULT_SETTINGS, ...JSON.parse(data) } : DEFAULT_SETTINGS;
+ } catch (error) {
+ console.error('[LocalStorage] Error getting settings:', error);
+ return DEFAULT_SETTINGS;
+ }
+ }
+
+ /**
+ * Save app settings
+ */
+ static async saveSettings(settings: AppSettings): Promise {
+ try {
+ await AsyncStorage.setItem(KEYS.SETTINGS, JSON.stringify(settings));
+ console.log('[LocalStorage] Settings saved');
+ } catch (error) {
+ console.error('[LocalStorage] Error saving settings:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Calculate and cache session statistics
+ */
+ static async calculateStats(): Promise {
+ try {
+ const sessions = await this.getAllSessions();
+
+ if (sessions.length === 0) {
+ return {
+ totalSessions: 0,
+ avgFocusScore: 0,
+ avgDuration: 0,
+ mostCommonPattern: null,
+ totalPatterns: 0,
+ lastSessionDate: null,
+ };
+ }
+
+ // Calculate averages
+ const totalFocusScore = sessions.reduce((sum, s) => sum + s.cognitiveMetrics.focusScore, 0);
+ const avgFocusScore = Math.round(totalFocusScore / sessions.length);
+
+ const totalDuration = sessions.reduce((sum, s) => sum + s.duration, 0);
+ const avgDuration = Math.round(totalDuration / sessions.length);
+
+ // Count patterns
+ const patternCounts: Record = {};
+ let totalPatterns = 0;
+
+ sessions.forEach((session) => {
+ session.detectedPatterns.forEach((pattern) => {
+ patternCounts[pattern.pattern] = (patternCounts[pattern.pattern] || 0) + 1;
+ totalPatterns++;
+ });
+ });
+
+ // Find most common pattern
+ let mostCommonPattern: NegotiationPattern | null = null;
+ let maxCount = 0;
+
+ Object.entries(patternCounts).forEach(([pattern, count]) => {
+ if (count > maxCount) {
+ maxCount = count;
+ mostCommonPattern = pattern as NegotiationPattern;
+ }
+ });
+
+ const stats: SessionStats = {
+ totalSessions: sessions.length,
+ avgFocusScore,
+ avgDuration,
+ mostCommonPattern,
+ totalPatterns,
+ lastSessionDate: sessions[0].timestamp,
+ };
+
+ // Cache stats
+ await AsyncStorage.setItem(KEYS.STATS, JSON.stringify(stats));
+
+ return stats;
+ } catch (error) {
+ console.error('[LocalStorage] Error calculating stats:', error);
+ return {
+ totalSessions: 0,
+ avgFocusScore: 0,
+ avgDuration: 0,
+ mostCommonPattern: null,
+ totalPatterns: 0,
+ lastSessionDate: null,
+ };
+ }
+ }
+
+ /**
+ * Get cached stats (faster, but may be slightly outdated)
+ */
+ static async getCachedStats(): Promise {
+ try {
+ const data = await AsyncStorage.getItem(KEYS.STATS);
+ return data ? JSON.parse(data) : null;
+ } catch (error) {
+ console.error('[LocalStorage] Error getting cached stats:', error);
+ return null;
+ }
+ }
+
+ /**
+ * Get stats (tries cache first, calculates if needed)
+ */
+ static async getStats(): Promise {
+ const cached = await this.getCachedStats();
+ if (cached) {
+ return cached;
+ }
+ return await this.calculateStats();
+ }
+
+ /**
+ * Clear all data (for settings screen)
+ */
+ static async clearAllData(): Promise {
+ try {
+ await this.deleteAllSessions();
+ await AsyncStorage.removeItem(KEYS.STATS);
+ console.log('[LocalStorage] All data cleared');
+ } catch (error) {
+ console.error('[LocalStorage] Error clearing all data:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Get storage size estimate (for debugging)
+ */
+ static async getStorageInfo(): Promise<{ keys: number; estimatedSize: string }> {
+ try {
+ const allKeys = await AsyncStorage.getAllKeys();
+ const latentKeys = allKeys.filter((key) => key.startsWith('@latent:'));
+
+ // Get approximate size
+ let totalSize = 0;
+ for (const key of latentKeys) {
+ const value = await AsyncStorage.getItem(key);
+ if (value) {
+ totalSize += value.length;
+ }
+ }
+
+ const sizeInKB = (totalSize / 1024).toFixed(2);
+
+ return {
+ keys: latentKeys.length,
+ estimatedSize: `${sizeInKB} KB`,
+ };
+ } catch (error) {
+ console.error('[LocalStorage] Error getting storage info:', error);
+ return { keys: 0, estimatedSize: '0 KB' };
+ }
+ }
+}
diff --git a/src/services/ModelService.tsx b/src/services/ModelService.tsx
index 56da189..cbbe787 100644
--- a/src/services/ModelService.tsx
+++ b/src/services/ModelService.tsx
@@ -1,5 +1,5 @@
-import React, { createContext, useContext, useState, useCallback } from 'react';
-import { RunAnywhere, ModelCategory } from '@runanywhere/core';
+import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
+import { RunAnywhere, ModelCategory, SDKEnvironment } from '@runanywhere/core';
import { LlamaCPP } from '@runanywhere/llamacpp';
import { ONNX, ModelArtifactType } from '@runanywhere/onnx';
@@ -12,6 +12,10 @@ const MODEL_IDS = {
} as const;
interface ModelServiceState {
+ // SDK readiness
+ isSDKReady: boolean;
+ sdkError: string | null;
+
// Download state
isLLMDownloading: boolean;
isSTTDownloading: boolean;
@@ -56,6 +60,10 @@ interface ModelServiceProviderProps {
}
export const ModelServiceProvider: React.FC = ({ children }) => {
+ // SDK readiness
+ const [isSDKReady, setIsSDKReady] = useState(false);
+ const [sdkError, setSDKError] = useState(null);
+
// Download state
const [isLLMDownloading, setIsLLMDownloading] = useState(false);
const [isSTTDownloading, setIsSTTDownloading] = useState(false);
@@ -76,6 +84,40 @@ export const ModelServiceProvider: React.FC = ({ chil
const [isTTSLoaded, setIsTTSLoaded] = useState(false);
const isVoiceAgentReady = isLLMLoaded && isSTTLoaded && isTTSLoaded;
+
+ // Initialize SDK on mount
+ useEffect(() => {
+ const initializeSDK = async () => {
+ try {
+ console.log('[ModelService] đ Initializing RunAnywhere SDK...');
+
+ // Initialize SDK
+ await RunAnywhere.initialize({
+ environment: SDKEnvironment.Development,
+ });
+ console.log('[ModelService] â
SDK initialized');
+
+ // Register backends
+ console.log('[ModelService] đĻ Registering backends...');
+ await LlamaCPP.register();
+ await ONNX.register();
+ console.log('[ModelService] â
Backends registered');
+
+ // Register default models
+ console.log('[ModelService] đ¤ Registering default models...');
+ await registerDefaultModels();
+ console.log('[ModelService] â
Default models registered');
+
+ setIsSDKReady(true);
+ console.log('[ModelService] â
SDK fully ready');
+ } catch (error) {
+ console.error('[ModelService] â SDK initialization failed:', error);
+ setSDKError(error instanceof Error ? error.message : 'SDK initialization failed');
+ }
+ };
+
+ initializeSDK();
+ }, []);
// Check if model is downloaded (per docs: use getModelInfo and check localPath)
const checkModelDownloaded = useCallback(async (modelId: string): Promise => {
@@ -123,36 +165,133 @@ export const ModelServiceProvider: React.FC = ({ chil
// Download and load STT
const downloadAndLoadSTT = useCallback(async () => {
- if (isSTTDownloading || isSTTLoading) return;
+ if (isSTTDownloading || isSTTLoading) {
+ console.log('[ModelService] âī¸ STT download/load already in progress, skipping');
+ return;
+ }
+
+ if (!isSDKReady) {
+ console.log('[ModelService] âŗ SDK not ready yet, waiting...');
+ return;
+ }
try {
- const isDownloaded = await checkModelDownloaded(MODEL_IDS.stt);
+ console.log('[ModelService] đ¤ Starting STT download and load...');
- if (!isDownloaded) {
+ // Step 1: Check if model files already exist on disk (e.g., pre-pushed via adb)
+ let modelLocalPath: string | null = null;
+
+ try {
+ // Check if the SDK already knows about the model
+ const isDownloaded = await checkModelDownloaded(MODEL_IDS.stt);
+ console.log('[ModelService] đĻ SDK reports model downloaded:', isDownloaded);
+
+ if (isDownloaded) {
+ const info = await RunAnywhere.getModelInfo(MODEL_IDS.stt);
+ if (info?.localPath) {
+ modelLocalPath = info.localPath;
+ console.log('[ModelService] â
Model found via SDK at:', modelLocalPath);
+ }
+ }
+ } catch (checkErr) {
+ console.log('[ModelService] â ī¸ SDK check failed, trying filesystem:', checkErr);
+ }
+
+ // If SDK doesn't know about it, check the filesystem directly
+ if (!modelLocalPath) {
+ try {
+ const RNFS = require('react-native-fs');
+ const documentsDir = RNFS.DocumentDirectoryPath;
+ const possiblePaths = [
+ `${documentsDir}/RunAnywhere/Models/ONNX/${MODEL_IDS.stt}/${MODEL_IDS.stt}`,
+ `${documentsDir}/RunAnywhere/Models/ONNX/${MODEL_IDS.stt}`,
+ ];
+
+ for (const path of possiblePaths) {
+ const exists = await RNFS.exists(path);
+ if (exists) {
+ // Verify it has model files
+ try {
+ const contents = await RNFS.readDir(path);
+ const hasOnnx = contents.some((f: any) => f.name.endsWith('.onnx'));
+ if (hasOnnx) {
+ modelLocalPath = path;
+ console.log('[ModelService] â
Model found on disk at:', path);
+ console.log('[ModelService] đ Contents:', contents.map((f: any) => f.name).join(', '));
+ break;
+ }
+ } catch {
+ // Not a directory, might be a file
+ }
+ }
+ }
+ } catch (fsErr) {
+ console.log('[ModelService] â ī¸ Filesystem check failed:', fsErr);
+ }
+ }
+
+ // Step 2: Download if not found on disk
+ if (!modelLocalPath) {
setIsSTTDownloading(true);
setSTTDownloadProgress(0);
- await RunAnywhere.downloadModel(MODEL_IDS.stt, (progress) => {
- setSTTDownloadProgress(progress.progress * 100);
- });
+ console.log('[ModelService] đĨ Model not found on disk, downloading...');
+ try {
+ await RunAnywhere.downloadModel(MODEL_IDS.stt, (progress) => {
+ const pct = progress.progress * 100;
+ setSTTDownloadProgress(pct);
+ });
+ console.log('[ModelService] â
STT model download completed');
+ } catch (downloadError: any) {
+ setIsSTTDownloading(false);
+ const errMsg = downloadError?.message || String(downloadError);
+ console.error('[ModelService] â STT download failed:', errMsg);
+ const { Alert } = require('react-native');
+ Alert.alert('Download Error', `STT model download failed:\n\n${errMsg}`);
+ throw downloadError;
+ }
setIsSTTDownloading(false);
+
+ // Get the path from SDK after download
+ try {
+ const modelInfo = await RunAnywhere.getModelInfo(MODEL_IDS.stt);
+ modelLocalPath = modelInfo?.localPath || null;
+ } catch (e) {
+ console.error('[ModelService] â Failed to get model path after download:', e);
+ }
+ }
+
+ if (!modelLocalPath) {
+ const errMsg = 'Could not find model files on disk after download';
+ console.error('[ModelService] â', errMsg);
+ const { Alert } = require('react-native');
+ Alert.alert('Model Error', errMsg);
+ throw new Error(errMsg);
}
- // Load the STT model (per docs: loadSTTModel(localPath, 'whisper'))
+ // Step 3: Load the STT model
setIsSTTLoading(true);
- const modelInfo = await RunAnywhere.getModelInfo(MODEL_IDS.stt);
- if (modelInfo?.localPath) {
- await RunAnywhere.loadSTTModel(modelInfo.localPath, 'whisper');
+ console.log('[ModelService] đ Loading STT model from:', modelLocalPath);
+ try {
+ await RunAnywhere.loadSTTModel(modelLocalPath, 'whisper');
+ console.log('[ModelService] â
STT model loaded successfully');
setIsSTTLoaded(true);
+ } catch (loadError: any) {
+ const errMsg = loadError?.message || String(loadError);
+ console.error('[ModelService] â STT model load failed:', errMsg);
+ const { Alert } = require('react-native');
+ Alert.alert('Model Load Error', `Path: ${modelLocalPath}\nError: ${errMsg}`);
+ throw loadError;
}
+
setIsSTTLoading(false);
- } catch (error) {
- console.error('STT download/load error:', error);
+ } catch (error: any) {
+ console.error('[ModelService] â STT pipeline error:', error?.message || error);
setIsSTTDownloading(false);
setIsSTTLoading(false);
}
- }, [isSTTDownloading, isSTTLoading, checkModelDownloaded]);
+ }, [isSTTDownloading, isSTTLoading, checkModelDownloaded, isSDKReady]);
// Download and load TTS
const downloadAndLoadTTS = useCallback(async () => {
@@ -211,6 +350,8 @@ export const ModelServiceProvider: React.FC = ({ chil
}, []);
const value: ModelServiceState = {
+ isSDKReady,
+ sdkError,
isLLMDownloading,
isSTTDownloading,
isTTSDownloading,
@@ -260,21 +401,23 @@ export const registerDefaultModels = async () => {
});
// STT Model - Sherpa Whisper Tiny English
- // Using tar.gz from RunanywhereAI/sherpa-onnx for fast native extraction
+ // Using tar.gz wrapper from RunAnywhere models
+ // Fallback: wrapping github download in ghproxy CDN to prevent DNS resolution errors
+ // Update: Switching to Hugging Face directly since ISP blocks ghproxy and github.com
await ONNX.addModel({
id: MODEL_IDS.stt,
name: 'Sherpa Whisper Tiny (ONNX)',
- url: 'https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/sherpa-onnx-whisper-tiny.en.tar.gz',
+ url: 'https://huggingface.co/runanywhere/sherpa-onnx-whisper-tiny.en/resolve/main/sherpa-onnx-whisper-tiny.en.tar.gz',
modality: ModelCategory.SpeechRecognition,
artifactType: ModelArtifactType.TarGzArchive,
- memoryRequirement: 75_000_000,
+ memoryRequirement: 100_000_000,
});
// TTS Model - Piper TTS (US English - Medium quality)
await ONNX.addModel({
id: MODEL_IDS.tts,
name: 'Piper TTS (US English - Medium)',
- url: 'https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/vits-piper-en_US-lessac-medium.tar.gz',
+ url: 'https://huggingface.co/runanywhere/vits-piper-en_US-lessac-medium/resolve/main/vits-piper-en_US-lessac-medium.tar.gz',
modality: ModelCategory.SpeechSynthesis,
artifactType: ModelArtifactType.TarGzArchive,
memoryRequirement: 65_000_000,
diff --git a/src/services/NegotiationAnalyzer.ts b/src/services/NegotiationAnalyzer.ts
new file mode 100644
index 0000000..15b157f
--- /dev/null
+++ b/src/services/NegotiationAnalyzer.ts
@@ -0,0 +1,261 @@
+/**
+ * đ PRIVACY NOTICE
+ * All pattern analysis runs locally on device.
+ * No external API calls. No data leaves this device.
+ */
+
+import { InteractionManager } from 'react-native';
+import {
+ NegotiationMode,
+ NegotiationPattern,
+ DetectedPattern,
+ TranscriptChunk,
+ AnalysisResult,
+} from '../types/session';
+import { classifyIntent, analyzeTranscriptWindow } from '../ai/intentClassifier';
+import { calculateCognitiveMetrics, calculateFocusScore } from '../ai/scoringEngine';
+import { getPatternDefinition } from '../ai/patternLibrary';
+
+/**
+ * NegotiationAnalyzer - Analyzes transcript for negotiation patterns
+ * Runs analysis in background to avoid blocking UI
+ */
+export class NegotiationAnalyzer {
+ private mode: NegotiationMode;
+ private sensitivityMultiplier: number;
+ private analysisInterval: NodeJS.Timeout | null = null;
+ private isAnalyzing: boolean = false;
+
+ constructor(mode: NegotiationMode = NegotiationMode.SALES, sensitivityMultiplier: number = 1.0) {
+ this.mode = mode;
+ this.sensitivityMultiplier = sensitivityMultiplier;
+ }
+
+ /**
+ * Analyze a single transcript chunk
+ */
+ analyzeChunk(text: string): DetectedPattern[] {
+ return classifyIntent(text, this.mode, this.sensitivityMultiplier);
+ }
+
+ /**
+ * Analyze multiple transcript chunks (window-based analysis)
+ */
+ analyzeWindow(chunks: TranscriptChunk[], windowSize: number = 1): DetectedPattern[] {
+ const recentChunks = chunks.slice(-windowSize).map((c) => ({
+ text: c.text,
+ timestamp: c.timestamp,
+ }));
+
+ return analyzeTranscriptWindow(recentChunks, this.mode, this.sensitivityMultiplier, windowSize);
+ }
+
+ /**
+ * Perform comprehensive analysis
+ * Returns patterns, cognitive metrics, and suggestions
+ */
+ async analyzeSession(
+ chunks: TranscriptChunk[],
+ sessionDuration: number
+ ): Promise {
+ // Run analysis in background to avoid blocking UI
+ return new Promise((resolve) => {
+ InteractionManager.runAfterInteractions(() => {
+ try {
+ // Analyze patterns from the most recent chunk to optimize speed and maintain high keyword density
+ const detectedPatterns = this.analyzeWindow(chunks, 1);
+
+ // Calculate cognitive metrics
+ const cognitiveMetrics = calculateCognitiveMetrics(
+ chunks.map((c) => ({ text: c.text, timestamp: c.timestamp })),
+ sessionDuration
+ );
+
+ // Calculate focus score
+ const focusScore = calculateFocusScore(cognitiveMetrics, sessionDuration);
+
+ // Generate suggestions based on patterns
+ const suggestions = this.generateSuggestions(detectedPatterns);
+
+ resolve({
+ detectedPatterns,
+ cognitiveMetrics,
+ suggestions,
+ focusScore,
+ });
+ } catch (error) {
+ console.error('[NegotiationAnalyzer] Analysis error:', error);
+ resolve({
+ detectedPatterns: [],
+ cognitiveMetrics: {},
+ suggestions: [],
+ focusScore: 100,
+ });
+ }
+ });
+ });
+ }
+
+ /**
+ * (Removed `startContinuousAnalysis` to migrate to debounced execution inline)
+ */
+
+ /**
+ * (Removed `stopContinuousAnalysis` to migrate to debounced execution inline)
+ */
+
+ /**
+ * Generate tactical suggestions based on detected patterns
+ */
+ private generateSuggestions(patterns: DetectedPattern[]): string[] {
+ const suggestions: string[] = [];
+ const seenSuggestions = new Set();
+
+ // Get top 3 highest confidence patterns
+ const topPatterns = patterns.sort((a, b) => b.confidenceScore - a.confidenceScore).slice(0, 3);
+
+ for (const pattern of topPatterns) {
+ if (!seenSuggestions.has(pattern.suggestion)) {
+ suggestions.push(pattern.suggestion);
+ seenSuggestions.add(pattern.suggestion);
+ }
+ }
+
+ return suggestions;
+ }
+
+ /**
+ * Generate session summary
+ */
+ generateSummary(
+ allPatterns: DetectedPattern[],
+ chunks: TranscriptChunk[]
+ ): {
+ leverageMoments: string[];
+ missedOpportunities: string[];
+ objectionCount: number;
+ positiveSignalCount: number;
+ tacticalSuggestions: string[];
+ keyInsights: string[];
+ } {
+ // Leverage moments (positive signals and commitments)
+ const leverageMoments: string[] = [];
+ allPatterns
+ .filter(
+ (p) =>
+ p.pattern === NegotiationPattern.POSITIVE_SIGNAL ||
+ p.pattern === NegotiationPattern.COMMITMENT_LANGUAGE
+ )
+ .sort((a, b) => b.confidenceScore - a.confidenceScore)
+ .slice(0, 5)
+ .forEach((p) => {
+ leverageMoments.push(
+ `${this.formatTimestamp(p.timestamp)}: ${p.context || p.transcript.substring(0, 50)}`
+ );
+ });
+
+ // Missed opportunities (deflections and negative signals without follow-up)
+ const missedOpportunities: string[] = [];
+ const deflections = allPatterns.filter(
+ (p) =>
+ p.pattern === NegotiationPattern.DEFLECTION ||
+ p.pattern === NegotiationPattern.NEGATIVE_SIGNAL
+ );
+ deflections.slice(0, 3).forEach((p) => {
+ const patternDef = getPatternDefinition(p.pattern);
+ missedOpportunities.push(
+ `${this.formatTimestamp(p.timestamp)}: ${patternDef.displayName} - Consider: ${p.suggestion}`
+ );
+ });
+
+ // Count objections (budget + authority)
+ const objectionCount = allPatterns.filter(
+ (p) =>
+ p.pattern === NegotiationPattern.BUDGET_OBJECTION ||
+ p.pattern === NegotiationPattern.AUTHORITY_PRESSURE
+ ).length;
+
+ // Count positive signals
+ const positiveSignalCount = allPatterns.filter(
+ (p) => p.pattern === NegotiationPattern.POSITIVE_SIGNAL
+ ).length;
+
+ // Tactical suggestions (most common patterns)
+ const tacticalSuggestions: string[] = [];
+ const patternCounts: Record = {};
+
+ allPatterns.forEach((p) => {
+ patternCounts[p.pattern] = (patternCounts[p.pattern] || 0) + 1;
+ });
+
+ const sortedPatterns = Object.entries(patternCounts)
+ .sort(([, a], [, b]) => b - a)
+ .slice(0, 3);
+
+ sortedPatterns.forEach(([pattern]) => {
+ const patternDef = getPatternDefinition(pattern as NegotiationPattern);
+ tacticalSuggestions.push(
+ `Prepare for ${patternDef.displayName}: ${patternDef.suggestions[0]}`
+ );
+ });
+
+ // Key insights
+ const keyInsights: string[] = [];
+ const totalPatterns = allPatterns.length;
+
+ if (positiveSignalCount > objectionCount) {
+ keyInsights.push('Strong positive momentum - capitalize on enthusiasm');
+ }
+ if (objectionCount > 5) {
+ keyInsights.push('High objection frequency - focus on value proposition');
+ }
+ if (deflections.length > 3) {
+ keyInsights.push('Multiple deferrals detected - establish concrete next steps');
+ }
+ if (totalPatterns < 5) {
+ keyInsights.push('Low pattern detection - conversation may need more depth');
+ }
+
+ return {
+ leverageMoments,
+ missedOpportunities,
+ objectionCount,
+ positiveSignalCount,
+ tacticalSuggestions,
+ keyInsights,
+ };
+ }
+
+ /**
+ * Format timestamp for display
+ */
+ private formatTimestamp(timestamp: number): string {
+ const date = new Date(timestamp);
+ return date.toLocaleTimeString('en-US', {
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ });
+ }
+
+ /**
+ * Update mode
+ */
+ setMode(mode: NegotiationMode): void {
+ this.mode = mode;
+ }
+
+ /**
+ * Update sensitivity
+ */
+ setSensitivity(multiplier: number): void {
+ this.sensitivityMultiplier = multiplier;
+ }
+
+ /**
+ * Cleanup
+ */
+ cleanup(): void {
+ // no continuous analysis to stop
+ }
+}
diff --git a/src/services/SessionEngine.ts b/src/services/SessionEngine.ts
new file mode 100644
index 0000000..60a7f13
--- /dev/null
+++ b/src/services/SessionEngine.ts
@@ -0,0 +1,549 @@
+/**
+ * đ PRIVACY NOTICE
+ * SessionEngine orchestrates all local processing.
+ * All data stays on device. Auto-saves every 45 seconds.
+ * No external API calls. No cloud sync.
+ */
+
+import {
+ Session,
+ LiveSessionState,
+ TranscriptChunk,
+ DetectedPattern,
+ NegotiationMode,
+ CognitiveMetrics,
+ SessionSummary,
+} from '../types/session';
+import { SpeechService } from './SpeechService';
+import { NegotiationAnalyzer } from './NegotiationAnalyzer';
+import { LocalStorageService } from './LocalStorageService';
+import { calculateCognitiveMetrics } from '../ai/scoringEngine';
+import { autoCorrectTranscript } from '../ai/WhisperAutoCorrector';
+
+export interface SessionUpdateCallback {
+ (state: LiveSessionState): void;
+}
+
+const DEBUG_TRANSCRIPTS: Record = {
+ [NegotiationMode.JOB_INTERVIEW]: [
+ 'We usually offer around 6 LPA for this position.',
+ 'I need to check with my manager before making any decision.',
+ 'Your profile looks interesting, but we have strict budget constraints.',
+ 'Let me think about it and get back to you next week.',
+ "This looks great! I'm really excited about moving forward.",
+ 'Are you willing to relocate for this role?',
+ 'I have authority to approve up to 8 LPA maximum.',
+ ],
+ [NegotiationMode.SALES]: [
+ 'Your product looks interesting, but the price is too high.',
+ 'We already use a competitor for this service.',
+ 'I need to get approval from the procurement department.',
+ 'What kind of discount can you offer us?',
+ 'We have budget constraints this quarter.',
+ 'Can we start with a pilot program first?',
+ ],
+ [NegotiationMode.STARTUP_PITCH]: [
+ 'What is your customer acquisition cost?',
+ 'We usually invest in later stage companies.',
+ 'Your valuation expectations seem a bit high.',
+ 'How do you plan to scale this next year?',
+ 'I need to check with my partners before committing.',
+ 'This looks great! I am really excited about moving forward.',
+ ],
+ [NegotiationMode.SALARY_RAISE]: [
+ 'Company performance has been slow this quarter.',
+ 'We usually only do appraisals at the end of the year.',
+ 'I agree you have done good work, but 30% is too much.',
+ 'Let me review the budget with HR and get back to you.',
+ 'Can we look at performance bonuses instead of a base hike?',
+ 'You are a valuable asset to the team.',
+ ],
+ [NegotiationMode.INVESTOR_MEETING]: [
+ 'Your burn rate is concerning for this stage.',
+ 'What is the exact runway you have left?',
+ 'We need to see more traction before releasing the next tranche.',
+ 'Who else is participating in this funding round?',
+ 'Your Go-to-market strategy seems expensive.',
+ ],
+ [NegotiationMode.CLIENT_NEGOTIATION]: [
+ 'The timeline for these deliverables is too tight.',
+ 'Can we reduce the retainer fee for the first three months?',
+ 'We need unlimited revisions included in this contract.',
+ 'We are waiting on our legal team to review the MSA.',
+ 'This scope is larger than what we initially discussed.',
+ ],
+ [NegotiationMode.CUSTOM_SCENARIO]: [
+ 'I need to check with my manager before making any decision.',
+ 'Let me think about it and get back to you next week.',
+ "This looks great! I'm really excited about moving forward.",
+ 'We have budget constraints this quarter.',
+ 'Can you send me more information via email?',
+ ],
+};
+
+/**
+ * SessionEngine - Orchestrates the entire session lifecycle
+ * Manages recording, transcription, analysis, and auto-save
+ */
+export class SessionEngine {
+ private sessionId: string | null = null;
+ private mode: NegotiationMode = NegotiationMode.SALES;
+ private speechService: SpeechService;
+ private analyzer: NegotiationAnalyzer;
+ private state: LiveSessionState;
+ private updateCallback: SessionUpdateCallback | null = null;
+ private autoSaveInterval: NodeJS.Timeout | null = null;
+ private analysisDebounceTrigger: NodeJS.Timeout | null = null;
+ private autoSaveIntervalMs: number = 45000; // 45 seconds as requested
+ private debugTranscriptIndex: number = 0;
+ private debugInterval: NodeJS.Timeout | null = null;
+ private debugMode: boolean = false;
+
+ constructor() {
+ this.speechService = new SpeechService();
+ this.analyzer = new NegotiationAnalyzer();
+ this.state = this.createInitialState();
+ }
+
+ /**
+ * Create initial session state
+ */
+ private createInitialState(): LiveSessionState {
+ return {
+ isRecording: false,
+ startTime: 0,
+ duration: 0,
+ transcript: [],
+ detectedPatterns: [],
+ currentFocusScore: 100,
+ audioLevel: 0,
+ lastAutoSave: 0,
+ };
+ }
+
+ /**
+ * Start a new session
+ */
+ async startSession(mode: NegotiationMode, onUpdate: SessionUpdateCallback): Promise {
+ try {
+ // Generate session ID
+ this.sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+ this.mode = mode;
+ this.updateCallback = onUpdate;
+
+ // Reset state
+ this.state = this.createInitialState();
+ this.state.startTime = Date.now();
+ this.state.isRecording = true;
+
+ // Configure analyzer
+ const settings = await LocalStorageService.getSettings();
+ this.analyzer.setMode(mode);
+ this.analyzer.setSensitivity(settings.patternSensitivity);
+
+ this.debugMode = settings.debugMode || false;
+
+ if (this.debugMode) {
+ console.log('[SessionEngine] đ DEBUG MODE ENABLED - Using hardcoded transcripts');
+ console.log('[SessionEngine] đ This bypasses STT and injects test data every 7 seconds');
+
+ // Start debug transcript injection
+ this.startDebugTranscripts();
+ } else {
+ // Start speech service
+ const started = await this.speechService.startRecording(
+ this.onTranscription.bind(this),
+ this.onAudioLevel.bind(this)
+ );
+
+ if (!started) {
+ console.error('[SessionEngine] Failed to start speech service');
+ return false;
+ }
+ }
+
+ // Removed: analyzer.startContinuousAnalysis
+ // Now running debounced in onTranscription
+
+ // Start auto-save (every 45 seconds)
+ this.startAutoSave();
+
+ console.log('[SessionEngine] Session started:', this.sessionId);
+ this.notifyUpdate();
+
+ return true;
+ } catch (error) {
+ console.error('[SessionEngine] Error starting session:', error);
+ return false;
+ }
+ }
+
+ /**
+ * Stop the current session and save
+ */
+ async stopSession(): Promise {
+ try {
+ if (!this.sessionId) {
+ return null;
+ }
+
+ console.log('[SessionEngine] Stopping session:', this.sessionId);
+
+ // Stop services
+ if (this.analysisDebounceTrigger) clearTimeout(this.analysisDebounceTrigger);
+ this.stopAutoSave();
+ this.stopDebugTranscripts();
+
+ if (!this.debugMode) {
+ await this.speechService.stopRecording();
+ }
+
+ this.state.isRecording = false;
+ this.state.duration = Date.now() - this.state.startTime;
+
+ // Calculate final cognitive metrics
+ const cognitiveMetrics = calculateCognitiveMetrics(
+ this.state.transcript.map((c) => ({ text: c.text, timestamp: c.timestamp })),
+ this.state.duration
+ );
+
+ // Generate summary
+ const summaryData = this.analyzer.generateSummary(
+ this.state.detectedPatterns,
+ this.state.transcript
+ );
+
+ const summary: SessionSummary = {
+ leverageMoments: summaryData.leverageMoments,
+ missedOpportunities: summaryData.missedOpportunities,
+ objectionCount: summaryData.objectionCount,
+ positiveSignalCount: summaryData.positiveSignalCount,
+ tacticalSuggestions: summaryData.tacticalSuggestions,
+ keyInsights: summaryData.keyInsights,
+ };
+
+ // Create final session object
+ const session: Session = {
+ id: this.sessionId,
+ timestamp: this.state.startTime,
+ duration: this.state.duration,
+ mode: this.mode,
+ transcript: this.state.transcript,
+ detectedPatterns: this.state.detectedPatterns,
+ cognitiveMetrics,
+ summary,
+ };
+
+ // Save to storage
+ await LocalStorageService.saveSession(session);
+
+ // Recalculate stats
+ await LocalStorageService.calculateStats();
+
+ console.log('[SessionEngine] Session saved:', session.id);
+
+ // Clean up
+ this.sessionId = null;
+ this.updateCallback = null;
+
+ return session;
+ } catch (error) {
+ console.error('[SessionEngine] Error stopping session:', error);
+ return null;
+ }
+ }
+
+ /**
+ * Cancel the current session without saving
+ */
+ async cancelSession(): Promise {
+ console.log('[SessionEngine] Canceling session');
+
+ if (this.analysisDebounceTrigger) clearTimeout(this.analysisDebounceTrigger);
+ this.stopAutoSave();
+ this.stopDebugTranscripts();
+
+ if (!this.debugMode) {
+ await this.speechService.cancelRecording();
+ }
+
+ this.state = this.createInitialState();
+ this.sessionId = null;
+ this.updateCallback = null;
+ }
+
+ private onTranscription(text: string, timestamp: number): void {
+ const rawText = text.trim();
+ if (!rawText) return;
+
+ // Remove blank tokens just in case
+ let cleanedText = rawText.replace(/\[BLANK_AUDIO\]/g, '').replace(/\[ Pause \]/gi, '').trim();
+ if (!cleanedText) return;
+
+ // đĄ Auto-correct Whisper 'tiny' hallucinations based on context vocabulary
+ cleanedText = autoCorrectTranscript(cleanedText, this.mode);
+
+ const transcriptLen = this.state.transcript.length;
+ let isNewSentence = true;
+
+ if (transcriptLen > 0) {
+ const lastChunk = this.state.transcript[transcriptLen - 1];
+ // Only break to a new bubble if the previous chunk ended with punctuation or it's a long pause
+ const isFinished = /[.!?]\s*$/.test(lastChunk.text);
+ const timeSinceLastUpdate = timestamp - lastChunk.timestamp;
+ const isLongPause = timeSinceLastUpdate > 10000;
+
+ if (!isFinished && !isLongPause) {
+ // BREAK REACT'S REFERENCE EQUALITY: Create a completely new object
+ // so FlatList detects that this item changed and actually re-renders!
+ this.state.transcript[transcriptLen - 1] = {
+ ...lastChunk,
+ text: `${lastChunk.text} ${cleanedText}`.trim(),
+ timestamp: timestamp,
+ };
+ isNewSentence = false;
+ }
+ }
+
+ if (isNewSentence) {
+ const chunk: TranscriptChunk = {
+ id: `chunk_${timestamp}_${Math.random().toString(36).substr(2, 9)}`,
+ text: cleanedText,
+ timestamp,
+ };
+ this.state.transcript.push(chunk);
+ }
+ this.state.duration = Date.now() - this.state.startTime;
+ this.triggerDebouncedAnalysis();
+ }
+
+ private triggerDebouncedAnalysis() {
+ this.notifyUpdate();
+
+ // Trigger debounced analysis inline
+ if (this.analysisDebounceTrigger) {
+ clearTimeout(this.analysisDebounceTrigger);
+ }
+
+ this.analysisDebounceTrigger = setTimeout(() => {
+ if (this.state.isRecording || this.debugMode) {
+ this.analyzer.analyzeSession(this.state.transcript, this.state.duration)
+ .then((result) => this.onAnalysisResult(result))
+ .catch((err) => console.error('[SessionEngine] Analysis error:', err));
+ }
+ }, 300);
+ }
+
+ /**
+ * Handle audio level updates
+ */
+ private onAudioLevel(level: number): void {
+ this.state.audioLevel = level;
+ this.notifyUpdate();
+ }
+
+ /**
+ * Handle analysis results
+ */
+ private onAnalysisResult(result: any): void {
+ console.log('[SessionEngine] đ§ onAnalysisResult() called');
+ console.log('[SessionEngine] đ¯ Detected patterns count:', result.detectedPatterns.length);
+ console.log('[SessionEngine] đ Focus score:', result.focusScore);
+
+ // Add new patterns (avoid duplicates)
+ const existingPatternIds = new Set(this.state.detectedPatterns.map((p) => p.id));
+ let newPatternsCount = 0;
+
+ result.detectedPatterns.forEach((pattern: DetectedPattern) => {
+ if (!existingPatternIds.has(pattern.id)) {
+ this.state.detectedPatterns.push(pattern);
+ newPatternsCount++;
+
+ console.log('[SessionEngine] đ New pattern detected:', {
+ pattern: pattern.pattern,
+ confidence: pattern.confidenceScore,
+ suggestion: pattern.suggestion,
+ });
+
+ // Mark related transcript chunks
+ this.state.transcript.forEach((chunk) => {
+ if (chunk.text === pattern.transcript) {
+ chunk.hasPattern = true;
+ }
+ });
+ }
+ });
+
+ // Sort patterns by confidence (highest first) so counter-strategy picks the best one
+ this.state.detectedPatterns.sort((a, b) => b.confidenceScore - a.confidenceScore);
+
+ // Update focus score
+ this.state.currentFocusScore = result.focusScore;
+
+ console.log('[SessionEngine] â
Analysis complete');
+ console.log('[SessionEngine] đ New patterns added:', newPatternsCount);
+ console.log('[SessionEngine] đ Total patterns:', this.state.detectedPatterns.length);
+
+ if (newPatternsCount > 0) {
+ // Force new array references so React detects the change
+ this.state.detectedPatterns = [...this.state.detectedPatterns];
+ }
+
+ this.notifyUpdate();
+ }
+
+ /**
+ * Start auto-save timer
+ */
+ private startAutoSave(): void {
+ this.autoSaveInterval = setInterval(() => {
+ this.performAutoSave();
+ }, this.autoSaveIntervalMs);
+
+ console.log('[SessionEngine] Auto-save started (every 45s)');
+ }
+
+ /**
+ * Stop auto-save timer
+ */
+ private stopAutoSave(): void {
+ if (this.autoSaveInterval) {
+ clearInterval(this.autoSaveInterval);
+ this.autoSaveInterval = null;
+ console.log('[SessionEngine] Auto-save stopped');
+ }
+ }
+
+ /**
+ * Perform auto-save (saves current state)
+ */
+ private async performAutoSave(): Promise {
+ if (!this.sessionId) {
+ return;
+ }
+
+ try {
+ // Calculate current cognitive metrics
+ const cognitiveMetrics = calculateCognitiveMetrics(
+ this.state.transcript.map((c) => ({ text: c.text, timestamp: c.timestamp })),
+ this.state.duration
+ );
+
+ // Create partial session for auto-save
+ const partialSession: Session = {
+ id: this.sessionId,
+ timestamp: this.state.startTime,
+ duration: Date.now() - this.state.startTime,
+ mode: this.mode,
+ transcript: this.state.transcript,
+ detectedPatterns: this.state.detectedPatterns,
+ cognitiveMetrics,
+ summary: {
+ leverageMoments: [],
+ missedOpportunities: [],
+ objectionCount: 0,
+ positiveSignalCount: 0,
+ tacticalSuggestions: [],
+ keyInsights: [],
+ },
+ };
+
+ await LocalStorageService.saveSession(partialSession);
+ this.state.lastAutoSave = Date.now();
+
+ console.log('[SessionEngine] Auto-save completed');
+ } catch (error) {
+ console.error('[SessionEngine] Auto-save error:', error);
+ }
+ }
+
+ /**
+ * Get current session state
+ */
+ getState(): LiveSessionState {
+ return { ...this.state };
+ }
+
+ /**
+ * Notify update callback
+ */
+ private notifyUpdate(): void {
+ if (this.updateCallback) {
+ console.log('[SessionEngine] đ Notifying state update to UI');
+ // Create new array references so React's useState detects the change
+ this.updateCallback({
+ ...this.state,
+ transcript: [...this.state.transcript],
+ detectedPatterns: [...this.state.detectedPatterns],
+ });
+ } else {
+ console.log('[SessionEngine] â ī¸ No update callback registered');
+ }
+ }
+
+ /**
+ * Update duration (call from UI timer)
+ */
+ updateDuration(): void {
+ if (this.state.isRecording) {
+ this.state.duration = Date.now() - this.state.startTime;
+ this.notifyUpdate();
+ }
+ }
+
+ /**
+ * Cleanup
+ */
+ cleanup(): void {
+ if (this.analysisDebounceTrigger) clearTimeout(this.analysisDebounceTrigger);
+ this.analyzer.cleanup();
+ this.speechService.cleanup();
+ this.stopAutoSave();
+ this.stopDebugTranscripts();
+ }
+
+ /**
+ * Start debug transcript injection (bypasses STT)
+ */
+ private startDebugTranscripts(): void {
+ console.log('[SessionEngine] đ Starting debug transcript injection');
+ this.debugTranscriptIndex = 0;
+
+ // Inject first transcript immediately
+ this.injectDebugTranscript();
+
+ // Inject new transcript every 7 seconds
+ this.debugInterval = setInterval(() => {
+ this.injectDebugTranscript();
+ }, 7000);
+ }
+
+ /**
+ * Stop debug transcript injection
+ */
+ private stopDebugTranscripts(): void {
+ if (this.debugInterval) {
+ clearInterval(this.debugInterval);
+ this.debugInterval = null;
+ console.log('[SessionEngine] đ Debug transcript injection stopped');
+ }
+ }
+
+ /**
+ * Inject a single debug transcript
+ */
+ private injectDebugTranscript(): void {
+ const activeTranscripts = DEBUG_TRANSCRIPTS[this.mode] || DEBUG_TRANSCRIPTS[NegotiationMode.CUSTOM_SCENARIO];
+
+ if (this.debugTranscriptIndex >= activeTranscripts.length) {
+ console.log('[SessionEngine] đ All debug transcripts injected, cycling back to start');
+ this.debugTranscriptIndex = 0;
+ }
+
+ const text = activeTranscripts[this.debugTranscriptIndex];
+ console.log(`[SessionEngine] đ Injecting debug transcript (${this.mode}):`, text);
+
+ this.onTranscription(text, Date.now());
+ this.debugTranscriptIndex++;
+ }
+}
diff --git a/src/services/SpeechService.ts b/src/services/SpeechService.ts
new file mode 100644
index 0000000..3e82dc3
--- /dev/null
+++ b/src/services/SpeechService.ts
@@ -0,0 +1,451 @@
+/**
+ * đ PRIVACY NOTICE
+ * All speech processing runs locally on device using RunAnywhere SDK.
+ * Audio is processed in memory and transcribed locally.
+ * No audio data is sent to external servers.
+ */
+
+import { NativeModules, Platform, PermissionsAndroid } from 'react-native';
+import { RunAnywhere } from '@runanywhere/core';
+
+const { NativeAudioModule } = NativeModules;
+
+// ============================================================================
+// STT MODEL STATUS HELPERS
+// NOTE: Model loading is handled by ModelService.downloadAndLoadSTT() in App.tsx
+// ============================================================================
+
+/**
+ * Check if STT model is ready (async check with RunAnywhere SDK)
+ */
+export const checkSTTModelReady = async (): Promise => {
+ try {
+ // First check if a model is actually loaded in the ONNX backend
+ const isLoaded = await RunAnywhere.isSTTModelLoaded();
+ if (isLoaded) {
+ console.log('[SpeechService] â
STT model is loaded (isSTTModelLoaded=true)');
+ return true;
+ }
+
+ // Fallback: check if model has a local path (downloaded but maybe not loaded)
+ const modelInfo = await RunAnywhere.getModelInfo('sherpa-onnx-whisper-base.en');
+ const hasLocalPath = !!modelInfo?.localPath;
+ console.log('[SpeechService] STT model check: isLoaded=false, hasLocalPath=', hasLocalPath);
+
+ if (hasLocalPath) {
+ // Model is downloaded but not loaded - try to load it
+ console.log('[SpeechService] đ Model downloaded but not loaded, attempting to load...');
+ try {
+ await RunAnywhere.loadSTTModel(modelInfo!.localPath!, 'whisper');
+ console.log('[SpeechService] â
STT model loaded successfully');
+ return true;
+ } catch (loadError) {
+ console.error('[SpeechService] â Failed to load STT model:', loadError);
+ return false;
+ }
+ }
+
+ return false;
+ } catch (error) {
+ console.log('[SpeechService] STT model check failed:', error);
+ return false;
+ }
+};
+
+export interface TranscriptionCallback {
+ (text: string, timestamp: number): void;
+}
+
+export interface AudioLevelCallback {
+ (level: number): void;
+}
+
+/**
+ * SpeechService - Handles audio recording and speech-to-text
+ */
+export class SpeechService {
+ private isRecording: boolean = false;
+ private recordingPath: string | null = null;
+ private recordingStartTime: number = 0;
+ private audioLevelInterval: NodeJS.Timeout | null = null;
+ private transcriptionInterval: NodeJS.Timeout | null = null;
+ private transcriptionCallback: TranscriptionCallback | null = null;
+ private audioLevelCallback: AudioLevelCallback | null = null;
+ private lastTranscriptionTime: number = 0;
+
+ /**
+ * Request microphone permission (Android only)
+ */
+ async requestPermission(): Promise {
+ if (Platform.OS !== 'android') {
+ console.log('[SpeechService] â
Platform is iOS, permission auto-granted');
+ return true;
+ }
+
+ try {
+ console.log('[SpeechService] đ¤ Requesting RECORD_AUDIO permission...');
+
+ const granted = await PermissionsAndroid.request(
+ PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
+ {
+ title: 'Microphone Permission',
+ message: 'Latent needs access to your microphone to transcribe speech.',
+ buttonNeutral: 'Ask Me Later',
+ buttonNegative: 'Cancel',
+ buttonPositive: 'OK',
+ }
+ );
+
+ const isGranted = granted === PermissionsAndroid.RESULTS.GRANTED;
+ console.log(`[SpeechService] ${isGranted ? 'â
' : 'â'} Permission ${granted}`);
+
+ return isGranted;
+ } catch (error) {
+ console.error('[SpeechService] â Permission error:', error);
+ return false;
+ }
+ }
+
+ /**
+ * Start recording audio
+ */
+ async startRecording(
+ onTranscription: TranscriptionCallback,
+ onAudioLevel?: AudioLevelCallback
+ ): Promise {
+ try {
+ console.log('[SpeechService] đī¸ startRecording() called');
+
+ // Check native module
+ if (!NativeAudioModule) {
+ console.error('[SpeechService] â NativeAudioModule not available');
+ return false;
+ }
+ console.log('[SpeechService] â
NativeAudioModule available');
+
+ // Request permission
+ const hasPermission = await this.requestPermission();
+ if (!hasPermission) {
+ console.error('[SpeechService] â Microphone permission denied');
+ return false;
+ }
+ console.log('[SpeechService] â
Microphone permission granted');
+
+ // Start native recording
+ console.log('[SpeechService] đŧ Starting native recording...');
+ const result = await NativeAudioModule.startRecording();
+
+ this.isRecording = true;
+ this.recordingPath = result.path;
+ this.recordingStartTime = Date.now();
+ this.transcriptionCallback = onTranscription;
+ this.audioLevelCallback = onAudioLevel || null;
+
+ console.log('[SpeechService] â
Recording started');
+ console.log('[SpeechService] đ Recording path:', result.path);
+ console.log('[SpeechService] â° Recording start time:', this.recordingStartTime);
+
+ // Start audio level polling
+ if (onAudioLevel) {
+ this.startAudioLevelPolling();
+ console.log('[SpeechService] đī¸ Audio level polling started');
+ }
+
+ // Start continuous transcription (every 5 seconds)
+ this.startContinuousTranscription();
+ console.log('[SpeechService] đ Continuous transcription started');
+
+ return true;
+ } catch (error) {
+ console.error('[SpeechService] â Error starting recording:', error);
+ this.isRecording = false;
+ return false;
+ }
+ }
+
+ /**
+ * Stop recording and transcribe
+ */
+ async stopRecording(): Promise {
+ try {
+ if (!this.isRecording || !NativeAudioModule) {
+ return null;
+ }
+
+ // Stop audio level polling
+ this.stopAudioLevelPolling();
+
+ // Stop continuous transcription
+ this.stopContinuousTranscription();
+
+ // Stop native recording
+ console.log('[SpeechService] Stopping recording...');
+ const result = await NativeAudioModule.stopRecording();
+
+ this.isRecording = false;
+ const audioPath = result.path || this.recordingPath;
+
+ if (!audioPath) {
+ console.error('[SpeechService] No audio path available');
+ return null;
+ }
+
+ // Transcribe the audio
+ console.log('[SpeechService] Transcribing audio...');
+ const transcription = await this.transcribeAudio(audioPath);
+
+ // Clean up
+ this.recordingPath = null;
+ this.transcriptionCallback = null;
+ this.audioLevelCallback = null;
+
+ return transcription;
+ } catch (error) {
+ console.error('[SpeechService] Error stopping recording:', error);
+ this.isRecording = false;
+ return null;
+ }
+ }
+
+ /**
+ * Cancel recording without transcribing
+ */
+ async cancelRecording(): Promise {
+ try {
+ if (!this.isRecording || !NativeAudioModule) {
+ return;
+ }
+
+ // Stop audio level polling
+ this.stopAudioLevelPolling();
+
+ // Stop continuous transcription
+ this.stopContinuousTranscription();
+
+ // Cancel native recording
+ await NativeAudioModule.cancelRecording();
+
+ this.isRecording = false;
+ this.recordingPath = null;
+ this.transcriptionCallback = null;
+ this.audioLevelCallback = null;
+
+ console.log('[SpeechService] Recording cancelled');
+ } catch (error) {
+ console.error('[SpeechService] Error cancelling recording:', error);
+ this.isRecording = false;
+ }
+ }
+
+ /**
+ * Transcribe audio file using RunAnywhere STT
+ */
+ private async transcribeAudio(audioPath: string): Promise {
+ try {
+ console.log('[SpeechService] đ¯ transcribeAudio() called');
+ console.log('[SpeechService] đ Audio path:', audioPath);
+
+ // Check if STT model is ready
+ const modelReady = await checkSTTModelReady();
+ if (!modelReady) {
+ console.error('[SpeechService] â STT model not ready');
+ throw new Error('STT model not loaded. Please ensure the model is downloaded.');
+ }
+
+ console.log('[SpeechService] â
STT model ready');
+
+ // Use the provided exact domain prompt bias string
+ const DOMAIN_PROMPT = "This conversation includes words like salary, fresher, offer, budget, negotiation, interview, compensation, package, client, proposal, anchor, objection, startup, candidate.";
+ console.log("[STT] Using domain prompt bias.");
+
+ // Use RunAnywhere.transcribeFile() API for file-based transcription
+ console.log('[SpeechService] đ¤ Running STT inference on file...');
+ const result = await RunAnywhere.transcribeFile(audioPath, {
+ initialPrompt: DOMAIN_PROMPT
+ } as any);
+
+ const transcription = result.text || '';
+ console.log('[SpeechService] â
Transcription complete');
+ console.log('[SpeechService] đ Text length:', transcription.length, 'chars');
+ console.log('[SpeechService] đ Text:', transcription);
+
+ // Call callback with transcription
+ if (this.transcriptionCallback) {
+ console.log('[SpeechService] đ¤ Calling transcription callback');
+ this.transcriptionCallback(transcription, Date.now());
+ } else {
+ console.log('[SpeechService] â ī¸ No transcription callback registered');
+ }
+
+ return transcription;
+ } catch (error) {
+ console.error('[SpeechService] â Transcription error:', error);
+ return null;
+ }
+ }
+
+ private startContinuousTranscription(): void {
+ this.lastTranscriptionTime = Date.now();
+
+ // Check interval every 500ms, but only transcribe when 1.5 seconds have passed
+ this.transcriptionInterval = setInterval(async () => {
+ try {
+ if (!this.isRecording) return;
+
+ const currentTime = Date.now();
+ const timeSinceLast = currentTime - this.lastTranscriptionTime;
+
+ // Extract and transcribe every 5.0 seconds for maximum stability and sentence context
+ if (timeSinceLast >= 5000) {
+ console.log(`[SpeechService] đ Processing ${timeSinceLast}ms chunk...`);
+
+ if (!NativeAudioModule) {
+ console.warn('[SpeechService] â ī¸ NativeAudioModule not available');
+ return;
+ }
+
+ try {
+ // Get EXACTLY the audio recorded since the last transcription. No padding, no overlap.
+ // This guarantees that Whisper evaluates the new chunk as a completely unique, sequential audio segment.
+ const snapshot = await NativeAudioModule.getRecentAudioSnapshot(timeSinceLast);
+ const snapshotPath = snapshot.path;
+
+ if (!snapshotPath || snapshot.fileSize === 0) {
+ console.log('[SpeechService] â ī¸ No audio data in snapshot yet');
+ return;
+ }
+
+ console.log(
+ `[SpeechService] đ¸ Got ${timeSinceLast}ms snapshot:`,
+ snapshotPath,
+ 'size:',
+ snapshot.fileSize
+ );
+
+ // Use the provided exact domain prompt bias string
+ const DOMAIN_PROMPT = "This conversation includes words like salary, fresher, offer, budget, negotiation, interview, compensation, package, client, proposal, anchor, objection, startup, candidate.";
+ console.log("[STT] Using domain prompt bias.");
+
+ // Transcribe the small snapshot file (O(1) time)
+ const result = await RunAnywhere.transcribeFile(snapshotPath, {
+ initialPrompt: DOMAIN_PROMPT
+ } as any);
+ const newText = (result.text || '').trim();
+
+ if (newText && newText.length > 0) {
+ // Sanitize whisper STT tokens that appear during silence
+ const sanitized = newText
+ .replace(/\[BLANK_AUDIO\]/g, '')
+ .replace(/\[ Pause \]/gi, '')
+ .replace(/\(Pause\)/gi, '')
+ .trim();
+
+ if (sanitized.length > 0) {
+ console.log('[SpeechService] â
New chunk transcription:', sanitized);
+ if (this.transcriptionCallback) {
+ this.transcriptionCallback(sanitized, currentTime);
+ }
+ } else {
+ console.log('[SpeechService] â ī¸ Chunk was only silence/pause tokens');
+ }
+ } else {
+ console.log('[SpeechService] â ī¸ Empty transcription result for chunk');
+ }
+
+ // Important: Only update time if successful, so we don't drop audio if it failed
+ this.lastTranscriptionTime = currentTime;
+
+ } catch (transcribeError) {
+ console.log('[SpeechService] â ī¸ Continuous transcription skipped:', transcribeError);
+ }
+ }
+ } catch (error) {
+ console.error('[SpeechService] â Continuous transcription error:', error);
+ }
+ }, 1000); // Check interval
+ }
+
+ /**
+ * Stop continuous transcription
+ */
+ private stopContinuousTranscription(): void {
+ if (this.transcriptionInterval) {
+ clearInterval(this.transcriptionInterval);
+ this.transcriptionInterval = null;
+ }
+ }
+
+ /**
+ * Start polling audio levels
+ */
+ private startAudioLevelPolling(): void {
+ let sampleCount = 0;
+
+ this.audioLevelInterval = setInterval(async () => {
+ try {
+ if (!NativeAudioModule || !this.isRecording) {
+ return;
+ }
+
+ const levelResult = await NativeAudioModule.getAudioLevel();
+ const level = levelResult.level || 0;
+
+ // Log first 3 samples for debugging
+ if (sampleCount < 3) {
+ console.log(`[SpeechService] đī¸ Audio level sample ${sampleCount + 1}:`, level);
+ sampleCount++;
+ } else if (sampleCount === 3) {
+ console.log('[SpeechService] đī¸ Audio level polling working (further logs suppressed)');
+ sampleCount++;
+ }
+
+ if (this.audioLevelCallback) {
+ this.audioLevelCallback(level);
+ }
+ } catch (error) {
+ // Ignore polling errors
+ }
+ }, 100); // Poll every 100ms
+ }
+
+ /**
+ * Stop polling audio levels
+ */
+ private stopAudioLevelPolling(): void {
+ if (this.audioLevelInterval) {
+ clearInterval(this.audioLevelInterval);
+ this.audioLevelInterval = null;
+ }
+ }
+
+ /**
+ * Get recording status
+ */
+ getIsRecording(): boolean {
+ return this.isRecording;
+ }
+
+ /**
+ * Get recording duration in milliseconds
+ */
+ getRecordingDuration(): number {
+ if (!this.isRecording) {
+ return 0;
+ }
+ return Date.now() - this.recordingStartTime;
+ }
+
+ /**
+ * Cleanup on service destruction
+ */
+ cleanup(): void {
+ this.stopAudioLevelPolling();
+ this.stopContinuousTranscription();
+ if (this.isRecording) {
+ this.cancelRecording();
+ }
+ }
+}
+
+// Export singleton instance
+export const speechService = new SpeechService();
diff --git a/src/state/sessionReducer.ts b/src/state/sessionReducer.ts
new file mode 100644
index 0000000..e385356
--- /dev/null
+++ b/src/state/sessionReducer.ts
@@ -0,0 +1,305 @@
+/**
+ * đ PRIVACY NOTICE
+ * All session state management runs locally on device.
+ * No data leaves this device.
+ */
+
+/**
+ * SESSION REDUCER
+ *
+ * Single source of truth for the entire live session lifecycle.
+ *
+ * WHY REDUCER PREVENTS STALE STATE:
+ * - Each action produces a deterministic new state from the previous state.
+ * - No stale closures: the reducer always gets the latest state as its first arg.
+ * - Unlike multiple useState() hooks, there's one atomic update per action â
+ * no half-updated states where transcript is new but suggestions are old.
+ *
+ * WHY COOLDOWN AVOIDS UI SPAM:
+ * - TACTIC_DETECTED checks lastTacticAtMs before updating suggestions.
+ * - If the same tactic fires within 8000ms, the action is ignored.
+ * - This prevents the suggestion card from flickering on/off rapidly.
+ */
+
+import {
+ NegotiationMode,
+ NegotiationPattern,
+ TranscriptChunk,
+ DetectedPattern,
+ CounterStrategy,
+} from '../types/session';
+import { generateCounterStrategies } from '../services/CounterStrategyEngine';
+
+// âââââââââââââââââââââââââââ Session Status âââââââââââââââââââââââââââ
+
+export type SessionStatus = 'IDLE' | 'RUNNING' | 'ENDED';
+
+// âââââââââââââââââââââââââââ State âââââââââââââââââââââââââââ
+
+export interface SessionState {
+ /** Current session lifecycle status */
+ status: SessionStatus;
+ /** Negotiation mode for pattern detection weights */
+ mode: NegotiationMode;
+ /** Live transcript chunks */
+ transcript: TranscriptChunk[];
+ /** Full transcript text (appended each chunk) */
+ transcriptText: string;
+ /** All detected patterns so far */
+ detectedPatterns: DetectedPattern[];
+ /** Currently detected tactic (highest confidence), or null */
+ tactic: NegotiationPattern | null;
+ /** Confidence of the current tactic (0-100) */
+ confidence: number;
+ /** Counter-strategy suggestions for the current tactic */
+ suggestions: string[];
+ /** Active counter-strategy card data, or null */
+ activeStrategy: CounterStrategy | null;
+ /** Timestamp (ms) when the last tactic was accepted (for cooldown) */
+ lastTacticAtMs: number;
+ /** Session start time */
+ startTime: number;
+ /** Session duration in ms */
+ duration: number;
+ /** Current cognitive focus score (0-100) */
+ focusScore: number;
+ /** Current audio level (0-1) */
+ audioLevel: number;
+ /** Last error message */
+ error: string | null;
+}
+
+// âââââââââââââââââââââââââââ Initial State âââââââââââââââââââââââââââ
+
+export const createInitialState = (
+ mode: NegotiationMode = NegotiationMode.SALES,
+): SessionState => ({
+ status: 'IDLE',
+ mode,
+ transcript: [],
+ transcriptText: '',
+ detectedPatterns: [],
+ tactic: null,
+ confidence: 0,
+ suggestions: [],
+ activeStrategy: null,
+ lastTacticAtMs: 0,
+ startTime: 0,
+ duration: 0,
+ focusScore: 100,
+ audioLevel: 0,
+ error: null,
+});
+
+// âââââââââââââââââââââââââââ Action Types âââââââââââââââââââââââââââ
+
+export type SessionAction =
+ | { type: 'START_SESSION'; mode: NegotiationMode; startTime: number }
+ | { type: 'STOP_SESSION' }
+ | { type: 'TRANSCRIPT_CHUNK'; chunk: TranscriptChunk }
+ | { type: 'SYNC_TRANSCRIPT'; transcript: TranscriptChunk[] }
+ | {
+ type: 'TACTIC_DETECTED';
+ patterns: DetectedPattern[];
+ focusScore: number;
+ timestampMs: number;
+ }
+ | { type: 'UPDATE_DURATION'; duration: number }
+ | { type: 'UPDATE_AUDIO_LEVEL'; level: number }
+ | { type: 'SET_ERROR'; message: string }
+ | { type: 'RESET' };
+
+// âââââââââââââââââââââââââââ Constants âââââââââââââââââââââââââââ
+
+/** Minimum confidence to accept a tactic (45%) */
+const CONFIDENCE_THRESHOLD = 45;
+
+/** Cooldown period to prevent same-tactic spam (8 seconds) */
+const TACTIC_COOLDOWN_MS = 8000;
+
+// âââââââââââââââââââââââââââ Reducer âââââââââââââââââââââââââââ
+
+export const sessionReducer = (
+ state: SessionState,
+ action: SessionAction,
+): SessionState => {
+ switch (action.type) {
+ // âââ Start Session âââ
+ case 'START_SESSION': {
+ console.log('[sessionReducer] âļī¸ START_SESSION mode:', action.mode);
+ return {
+ ...createInitialState(action.mode),
+ status: 'RUNNING',
+ startTime: action.startTime,
+ };
+ }
+
+ // âââ Stop Session âââ
+ case 'STOP_SESSION': {
+ console.log('[sessionReducer] âšī¸ STOP_SESSION');
+ return {
+ ...state,
+ status: 'ENDED',
+ };
+ }
+
+ // âââ Append Transcript Chunk âââ
+ case 'TRANSCRIPT_CHUNK': {
+ // Guard: ignore if session not running
+ if (state.status !== 'RUNNING') {
+ console.log('[sessionReducer] â ī¸ TRANSCRIPT_CHUNK ignored â session not running');
+ return state;
+ }
+
+ console.log('[sessionReducer] đ TRANSCRIPT_CHUNK:', action.chunk.text.substring(0, 40));
+
+ return {
+ ...state,
+ transcript: [...state.transcript, action.chunk],
+ transcriptText: state.transcriptText + ' ' + action.chunk.text,
+ };
+ }
+
+ // âââ Sync Transcript Array âââ
+ case 'SYNC_TRANSCRIPT': {
+ if (state.status !== 'RUNNING') return state;
+ return {
+ ...state,
+ transcript: action.transcript,
+ transcriptText: action.transcript.map(c => c.text).join(' '),
+ };
+ }
+
+ // âââ Tactic Detected (from debounced analysis) âââ
+ case 'TACTIC_DETECTED': {
+ // Guard 1: ignore if session not running (prevents unmounted updates)
+ if (state.status !== 'RUNNING') {
+ console.log('[sessionReducer] â ī¸ TACTIC_DETECTED ignored â session not running');
+ return state;
+ }
+
+ const { patterns, focusScore, timestampMs } = action;
+
+ // Merge new patterns (avoid duplicates)
+ const existingIds = new Set(state.detectedPatterns.map((p) => p.id));
+ const newPatterns = patterns.filter((p) => !existingIds.has(p.id));
+ const allPatterns = [...state.detectedPatterns, ...newPatterns];
+
+ // Mark transcript chunks that have patterns
+ const updatedTranscript = state.transcript.map((chunk) => {
+ const hasPattern = newPatterns.some((p) => p.transcript === chunk.text);
+ return hasPattern ? { ...chunk, hasPattern: true } : chunk;
+ });
+
+ // If no NEW patterns were detected on this tick, do not reset or calculate strategies.
+ // Just update the base focusScore.
+ if (newPatterns.length === 0) {
+ return {
+ ...state,
+ detectedPatterns: allPatterns,
+ transcript: updatedTranscript,
+ focusScore,
+ };
+ }
+
+ // Get the strongest pattern from the CURRENT newly analyzed audio block!
+ // We ignore allPatterns entirely here to prevent old history from overriding fresh intel.
+ newPatterns.sort((a, b) => b.confidenceScore - a.confidenceScore);
+ const topPattern = newPatterns[0] || null;
+
+ if (!topPattern) {
+ return {
+ ...state,
+ detectedPatterns: allPatterns,
+ transcript: updatedTranscript,
+ focusScore,
+ };
+ }
+
+ console.log(
+ '[sessionReducer] đ¯ NEW TACTIC_DETECTED:',
+ topPattern.pattern,
+ 'confidence:', topPattern.confidenceScore,
+ );
+
+ // Guard 2: confidence threshold
+ if (topPattern.confidenceScore < CONFIDENCE_THRESHOLD) {
+ console.log(
+ `[sessionReducer] â Confidence ${topPattern.confidenceScore}% below threshold ${CONFIDENCE_THRESHOLD}%`,
+ );
+ return {
+ ...state,
+ detectedPatterns: allPatterns,
+ transcript: updatedTranscript,
+ focusScore,
+ };
+ }
+
+ // Guard 3: cooldown â don't repeat same tactic within 8s
+ if (
+ topPattern.pattern === state.tactic &&
+ timestampMs - state.lastTacticAtMs < TACTIC_COOLDOWN_MS
+ ) {
+ const remaining = Math.round(
+ (TACTIC_COOLDOWN_MS - (timestampMs - state.lastTacticAtMs)) / 1000,
+ );
+ console.log(
+ `[sessionReducer] âŗ Cooldown: "${topPattern.pattern}" â ${remaining}s remaining`,
+ );
+ return {
+ ...state,
+ detectedPatterns: allPatterns,
+ transcript: updatedTranscript,
+ focusScore,
+ };
+ }
+
+ // All guards passed â generate counter-strategy suggestions
+ console.log('[sessionReducer] â
Generating counter-strategy for:', topPattern.pattern);
+
+ const strategy = generateCounterStrategies(
+ topPattern.pattern,
+ topPattern.confidenceScore,
+ TACTIC_COOLDOWN_MS,
+ CONFIDENCE_THRESHOLD,
+ );
+
+ return {
+ ...state,
+ detectedPatterns: allPatterns,
+ transcript: updatedTranscript,
+ tactic: topPattern.pattern,
+ confidence: topPattern.confidenceScore,
+ suggestions: strategy ? strategy.suggestions : state.suggestions,
+ activeStrategy: strategy || state.activeStrategy,
+ lastTacticAtMs: timestampMs,
+ focusScore,
+ };
+ }
+
+ // âââ Update Duration âââ
+ case 'UPDATE_DURATION': {
+ return { ...state, duration: action.duration };
+ }
+
+ // âââ Update Audio Level âââ
+ case 'UPDATE_AUDIO_LEVEL': {
+ return { ...state, audioLevel: action.level };
+ }
+
+ // âââ Set Error âââ
+ case 'SET_ERROR': {
+ console.log('[sessionReducer] â SET_ERROR:', action.message);
+ return { ...state, error: action.message };
+ }
+
+ // âââ Reset âââ
+ case 'RESET': {
+ console.log('[sessionReducer] đ RESET');
+ return createInitialState(state.mode);
+ }
+
+ default:
+ return state;
+ }
+};
diff --git a/src/theme/colors.ts b/src/theme/colors.ts
index 39be4bc..ef39f29 100644
--- a/src/theme/colors.ts
+++ b/src/theme/colors.ts
@@ -1,31 +1,66 @@
/**
- * App color palette - Inspired by modern AI/tech aesthetics
- * Matching the Flutter app's beautiful theme
+ * App color palette - Premium Light Purple Fintech Theme
+ * Inspired by modern banking/fintech app design
+ * Soft lavender backgrounds with purple accent cards
*/
export const AppColors = {
- // Primary gradient colors - Deep space with electric accents
- primaryDark: '#0A0E1A',
- primaryMid: '#141B2D',
- surfaceCard: '#1C2438',
- surfaceElevated: '#242F4A',
-
- // Accent colors - Electric cyan, violet, and more
- accentCyan: '#00D9FF',
- accentViolet: '#8B5CF6',
- accentPink: '#EC4899',
- accentGreen: '#10B981',
- accentOrange: '#F59E0B',
+ // Backgrounds - Light lavender/purple
+ primaryLight: '#F5F0FF',
+ primaryBg: '#EDE5FF',
+ primaryMid: '#E8DFFF',
+ surfaceCard: '#FFFFFF',
+ surfaceElevated: '#F8F5FF',
+
+ // NOT USED for backward compat â mapped to new palette
+ primaryDark: '#F5F0FF',
+
+ // Purple gradient accent
+ purpleGradientStart: '#7B61FF',
+ purpleGradientEnd: '#9B82FF',
+ purpleLight: '#C4B5FD',
+ purpleMuted: '#DDD6FE',
+ purpleSoft: '#EDE9FE',
+
+ // Primary accent - Purple
+ accentPrimary: '#7B61FF',
+ accentSecondary: '#9B82FF',
+ accentTeal: '#7B61FF', // backward compat alias
+ accentTealLight: '#9B82FF',
+ accentTealDark: '#6C4DE6',
+ accentCyan: '#7B61FF', // backward compat
+ accentViolet: '#6C4DE6',
+ accentPink: '#F472B6',
+ accentGreen: '#34C759',
+ accentOrange: '#FF9F0A',
// Text colors
- textPrimary: '#F1F5F9',
- textSecondary: '#94A3B8',
- textMuted: '#64748B',
+ textPrimary: '#1A1A2E',
+ textSecondary: '#6B7280',
+ textMuted: '#9CA3AF',
// Status colors
- success: '#22C55E',
- warning: '#F59E0B',
- error: '#EF4444',
- info: '#3B82F6',
+ success: '#34C759',
+ warning: '#FF9F0A',
+ error: '#FF3B30',
+ info: '#007AFF',
+
+ // Transaction icon colors
+ iconOrange: '#FF9F43',
+ iconBlue: '#54A0FF',
+ iconGreen: '#00D2D3',
+ iconPurple: '#A29BFE',
+ iconPink: '#FF6B6B',
+ iconTeal: '#1DD1A1',
+
+ // Glassmorphism / card styling
+ glassBg: 'rgba(255, 255, 255, 0.7)',
+ glassBgLight: 'rgba(255, 255, 255, 0.85)',
+ glassBorder: 'rgba(123, 97, 255, 0.12)',
+ glassBorderLight: 'rgba(123, 97, 255, 0.08)',
+
+ // Shadow
+ shadowPurple: '#7B61FF',
+ shadowDark: 'rgba(0, 0, 0, 0.08)',
} as const;
export type AppColorsType = typeof AppColors;
diff --git a/src/types/session.ts b/src/types/session.ts
new file mode 100644
index 0000000..3db6c67
--- /dev/null
+++ b/src/types/session.ts
@@ -0,0 +1,211 @@
+/**
+ * đ PRIVACY NOTICE
+ * All data structures defined here are stored locally on device.
+ * No data leaves this device.
+ */
+
+/**
+ * Negotiation modes with different pattern detection weights
+ */
+export enum NegotiationMode {
+ JOB_INTERVIEW = 'job_interview',
+ SALES = 'sales',
+ STARTUP_PITCH = 'startup_pitch',
+ SALARY_RAISE = 'salary_raise',
+ INVESTOR_MEETING = 'investor_meeting',
+ CLIENT_NEGOTIATION = 'client_negotiation',
+ CUSTOM_SCENARIO = 'custom_scenario',
+}
+
+/**
+ * Negotiation patterns that can be detected
+ */
+export enum NegotiationPattern {
+ ANCHORING = 'anchoring',
+ BUDGET_OBJECTION = 'budget_objection',
+ AUTHORITY_PRESSURE = 'authority_pressure',
+ TIME_PRESSURE = 'time_pressure',
+ DEFLECTION = 'deflection',
+ POSITIVE_SIGNAL = 'positive_signal',
+ NEGATIVE_SIGNAL = 'negative_signal',
+ COMMITMENT_LANGUAGE = 'commitment_language',
+ STRENGTH_SIGNAL = 'strength_signal',
+}
+
+/**
+ * Severity levels for detected patterns
+ */
+export type PatternSeverity = 'low' | 'medium' | 'high';
+
+/**
+ * Detected pattern with metadata
+ */
+export interface DetectedPattern {
+ id: string;
+ pattern: NegotiationPattern;
+ confidenceScore: number; // 0-100
+ suggestion: string;
+ severity: PatternSeverity;
+ timestamp: number;
+ transcript: string;
+ context?: string;
+}
+
+/**
+ * Cognitive metrics tracked during session
+ */
+export interface CognitiveMetrics {
+ focusScore: number; // 0-100
+ speechGaps: number; // Count of pauses > 2 seconds
+ fillerWords: number; // Count of um, uh, like, etc.
+ avgSpeechRate: number; // Words per minute
+ totalWords: number;
+ speechDuration: number; // Milliseconds of actual speech (excluding gaps)
+}
+
+/**
+ * Session summary generated after session ends
+ */
+export interface SessionSummary {
+ leverageMoments: string[]; // Key moments where user had advantage
+ missedOpportunities: string[]; // Moments where better response was possible
+ objectionCount: number;
+ positiveSignalCount: number;
+ tacticalSuggestions: string[]; // Suggestions for next meeting
+ keyInsights: string[];
+}
+
+/**
+ * Transcript chunk with timestamp
+ */
+export interface TranscriptChunk {
+ id: string;
+ text: string;
+ timestamp: number;
+ speaker?: 'user' | 'other'; // Future enhancement for multi-speaker
+ hasPattern?: boolean; // True if this chunk has detected patterns
+}
+
+/**
+ * Live session state (in-memory during recording)
+ */
+export interface LiveSessionState {
+ isRecording: boolean;
+ startTime: number;
+ duration: number;
+ transcript: TranscriptChunk[];
+ detectedPatterns: DetectedPattern[];
+ currentFocusScore: number;
+ audioLevel: number;
+ lastAutoSave: number;
+}
+
+/**
+ * Adaptive Inputs collected before session start
+ */
+export interface PreSessionInputs {
+ [key: string]: string; // Key-value maps for dynamic form questions
+}
+
+/**
+ * 10-Point Strategic Analysis Plan generated offline
+ */
+export interface StrategicAnalysis {
+ powerPositioning: string;
+ likelyObjections: string[];
+ psychologicalTactics: string[];
+ recommendedResponses: string[];
+ highImpactPhrases: string[];
+ phrasesToAvoid: string[];
+ confidenceTriggers: string[];
+ openingScript: string;
+ closingScript: string;
+ mistakesToAvoid: string[];
+}
+
+/**
+ * Persisted session data (saved to AsyncStorage)
+ */
+export interface Session {
+ id: string;
+ timestamp: number; // Session start time
+ duration: number; // Total duration in milliseconds
+ mode: NegotiationMode;
+ preSessionInputs?: PreSessionInputs;
+ strategicAnalysis?: StrategicAnalysis;
+ transcript: TranscriptChunk[];
+ detectedPatterns: DetectedPattern[];
+ cognitiveMetrics: CognitiveMetrics;
+ summary: SessionSummary;
+ title?: string; // Optional user-provided title
+ notes?: string; // Optional user notes
+}
+
+/**
+ * Quick stats for home screen dashboard
+ */
+export interface SessionStats {
+ totalSessions: number;
+ avgFocusScore: number;
+ avgDuration: number;
+ mostCommonPattern: NegotiationPattern | null;
+ totalPatterns: number;
+ lastSessionDate: number | null;
+}
+
+/**
+ * App settings stored in AsyncStorage
+ */
+export interface AppSettings {
+ defaultMode: NegotiationMode;
+ patternSensitivity: number; // 0.5 - 1.5 multiplier for confidence thresholds
+ enableAutoSave: boolean;
+ autoSaveInterval: number; // Milliseconds (default 45000)
+ enableHapticFeedback: boolean;
+ enableSuggestionNotifications: boolean;
+ debugMode?: boolean; // Enable debug mode with hardcoded transcripts
+}
+
+/**
+ * Pattern detection configuration per mode
+ */
+export interface ModeConfig {
+ mode: NegotiationMode;
+ displayName: string;
+ description: string;
+ icon: string;
+ patternWeights: {
+ [key in NegotiationPattern]: number; // Multiplier for confidence score
+ };
+}
+
+/**
+ * Audio buffer chunk for processing
+ */
+export interface AudioChunk {
+ data: number[];
+ timestamp: number;
+ duration: number;
+}
+
+/**
+ * Analysis result from NegotiationAnalyzer
+ */
+export interface AnalysisResult {
+ detectedPatterns: DetectedPattern[];
+ cognitiveMetrics: Partial;
+ suggestions: string[];
+ focusScore: number;
+}
+
+/**
+ * Counter strategy result from the CounterStrategyEngine
+ */
+export interface CounterStrategy {
+ tactic: NegotiationPattern;
+ tacticDisplayName: string;
+ confidence: number; // 0-100
+ suggestions: string[];
+ explanation: string;
+ timestamp: number;
+}
diff --git a/tech_stack_info.md b/tech_stack_info.md
new file mode 100644
index 0000000..db5a0e6
--- /dev/null
+++ b/tech_stack_info.md
@@ -0,0 +1,65 @@
+# Latent: Technical Architecture & Stack Overview
+
+**Latent** is a privacy-first, on-device Conversation Intelligence Engine built primarily to analyze live negotiations, interviews, and pitches in real-time without ever sending audio or transcriptions to the cloud.
+
+---
+
+## Core Technology Stack
+
+- **Application Framework:** React Native (TypeScript)
+- **Target Platforms:** Android & iOS
+- **State Management & UI:** React Hooks, Functional Components, Custom CSS-in-JS (React Native StyleSheets).
+- **Routing:** React Navigation (Stack Navigator)
+- **Local Storage:** AsyncStorage (for saving post-session analysis & settings offline)
+
+---
+
+## On-Device AI: The RunAnywhere SDKs
+
+The entire artificial intelligence pipeline is powered exclusively by the **RunAnywhere SDK**. This allows massive neural network models (LLMs, STT, TTS) to run locally on the mobile phone's CPU/GPU.
+
+The following SDK modules are integrated into Latent:
+
+1. **`@runanywhere/core`**
+ - The foundational bridging layer that interfaces between React Native JavaScript and the underlying C++ native environment.
+2. **`@runanywhere/onnx`**
+ - The ONNX Runtime backend. This powers the **Speech-to-Text (STT)** engine using the _Sherpa Whisper Tiny_ model.
+ - Also acts as the execution engine for the **Text-to-Speech (TTS)** pipeline using the _Piper TTS_ model.
+3. **`@runanywhere/llamacpp`**
+ - The Llama.cpp backend used for running full **Large Language Models (LLMs)** on-device, such as _Liquid LFM2 (350M)_ or _SmolLM2 (360M)_, without cloud latency.
+
+---
+
+## Data Transmission & Input Flow
+
+The most critical architectural pillar of Latent is the **Zero-Cloud Data Policy**. Here is how data is inputted, transmitted, and processed entirely within the phone's memory.
+
+### 1. Audio Acquisition (Input)
+
+- The application requests local microphone permissions (`android.permission.RECORD_AUDIO`).
+- Audio is captured via native audio modules (`react-native-live-audio-stream` / underlying C++ audio capture buffers).
+- **Transmission:** Audio data is passed directly from the microphone hardware into local RAM (Float32 buffers or temporary WAV snapshots). **It never leaves the device.**
+
+### 2. Live Transcription Pipeline (STT)
+
+- The `SpeechService.ts` module routinely chunks the incoming audio (e.g., every 1.5 to 5 seconds).
+- These localized audio snapshots are handed to the `RunAnywhere.transcribeFile()` C++ bridge.
+- The **Sherpa Whisper Tiny ONNX** model analyzes the audio purely on local hardware. We inject a _Domain Bias Prompt_ natively to optimize the STT context for words involving finance, startup, and negotiation (e.g., prioritizing "salary" over "celery").
+- The C++ bridge returns the recognized text string back up to the JavaScript layer.
+
+### 3. Contextual Auto-Correction & Parsing
+
+- Upon receiving the raw text string, Latent passes it through a zero-latency, synchronous TypeScript `WhisperAutoCorrector`.
+- This uses **Levenshtein Distance** algorithms to fuzzy-match phonetic hallucinations against the active _Negotiation Mode_ (e.g., Job Interview vs. Investor Meeting) vocabulary.
+
+### 4. Intent & Counter-Strategy Generation
+
+- The sanitized transcript string is injected into the `SessionEngine`, which feeds it to the `IntentClassifier`.
+- The engine evaluates the sentence against an offline `Universal Scoring Engine` (powered by structural pattern matching, negation rules, and numeric markers).
+- If a specific tactic is detected (e.g., _Authority Pressure_ or _Budget Objection_), the `CounterStrategyEngine` instantly surfaces actionable suggestions on the React Native UI.
+
+### 5. Final Output & Offline Storage
+
+- When a session ends, the full dialog map and cognitive metrics are compiled.
+- Data is saved directly to local flash storage via `AsyncStorage`.
+- The data is then displayed on the _Outcome Replay Screen_ for post-meeting analysis, remaining 100% private to the physical device owner.
diff --git a/tech_stack_info.pdf b/tech_stack_info.pdf
new file mode 100644
index 0000000..b02243b
Binary files /dev/null and b/tech_stack_info.pdf differ