There is a category of emotional data that only speaking out loud can capture. When people write in a journal, they unconsciously edit. They choose words, soften edges, and present a tidier version of what actually happened. Voice is different. The hesitations, the change in tone mid-sentence, the word choices that emerge under no pressure to sound good — these carry raw emotional signal that most text entry erases automatically.
I started thinking about this gap while using a standard voice memo app and realizing that transcription technology had gotten good enough to extract real meaning, but nobody was doing much with the transcript after the fact. The pipeline seemed obvious: audio → Whisper → Claude → emotion score → chart. When I built it out as a Rork app, what surprised me was how quickly it became something I used every single day — not because I set a reminder, but because looking at my own emotional data from the previous week was genuinely interesting.
This guide walks through the complete implementation of that pipeline. Not just the happy path, but the parts that Rork alone won't get right without careful prompting: audio session lifecycle management, API key security, Claude JSON robustness, emotion score validation, privacy-first data handling, and how to think about the subscription model boundary. Code examples are complete and production-oriented, not teaching snippets.
Architecture Overview — Four Layers, One Clean Pipeline
Before writing any code or prompting Rork, establish the architecture. A Rork prompt that describes a four-layer design produces significantly more coherent component separation than one that says "build a voice diary with AI." Give Rork the mental model first.
Layer 1 — Capture
expo-av (audio recording) → AudioFile persisted to device storage with a timestamped filename
Layer 2 — Transcription
AudioFile → Whisper API (OpenAI) → TranscriptText string
Layer 3 — Analysis
TranscriptText → Claude API (Anthropic) → EmotionScore (structured JSON)
Layer 4 — Storage and Visualization
EmotionScore → Supabase (cloud sync, user-authenticated) + expo-secure-store (encrypted local cache) + Victory Native (trend chart rendering)
The key architectural decision is that Layer 2 and Layer 3 never run in the client directly against production API keys. Both transcription and emotion analysis go through a server-side intermediary (a Cloudflare Worker or Supabase Edge Function) that holds the API keys and validates the user's auth token. This is not optional for a production app.
Audio Recording — expo-av Lifecycle and the Mistakes Rork Makes
The Complete Recording Hook
expo-av is the standard recording library for Expo-based React Native apps, and Rork knows how to scaffold the basics. But the scaffolded code reliably omits one critical step: resetting the audio mode after recording stops. Here is the complete, correct implementation.
// components/VoiceRecorder.tsx
import { useState, useRef } from 'react';
import { Audio } from 'expo-av';
import { Alert } from 'react-native';
type RecordingStatus = 'idle' | 'recording' | 'stopped' | 'error';
interface RecordingResult {
uri: string;
duration: number; // seconds
createdAt: Date;
}
export function useVoiceRecorder() {
const [status, setStatus] = useState<RecordingStatus>('idle');
const [duration, setDuration] = useState(0);
const recordingRef = useRef<Audio.Recording | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const requestPermission = async (): Promise<boolean> => {
const { status: permStatus } = await Audio.requestPermissionsAsync();
if (permStatus !== 'granted') {
Alert.alert(
'Microphone access required',
'Please go to Settings and enable microphone access for this app.',
[{ text: 'OK' }]
);
return false;
}
return true;
};
const startRecording = async (): Promise<void> => {
const hasPermission = await requestPermission();
if (!hasPermission) return;
try {
// iOS requires explicit audio session setup before recording can begin.
// Android handles this automatically, but the call is safe to make on both platforms.
await Audio.setAudioModeAsync({
allowsRecordingIOS: true,
playsInSilentModeIOS: true,
staysActiveInBackground: false,
});
const { recording } = await Audio.Recording.createAsync(
Audio.RecordingOptionsPresets.HIGH_QUALITY
);
recordingRef.current = recording;
setStatus('recording');
setDuration(0);
timerRef.current = setInterval(() => {
setDuration((prev) => prev + 1);
}, 1000);
} catch (error) {
console.error('Recording start error:', error);
setStatus('error');
}
};
const stopRecording = async (): Promise<RecordingResult | null> => {
if (!recordingRef.current) return null;
try {
if (timerRef.current) clearInterval(timerRef.current);
await recordingRef.current.stopAndUnloadAsync();
// ⚠️ This reset is what Rork-generated code almost always omits.
// Without it, the audio session stays in recording mode.
// On the next app launch, the speaker will be silent until the user
// manually toggles the ringer or restarts the device.
await Audio.setAudioModeAsync({
allowsRecordingIOS: false,
playsInSilentModeIOS: true,
});
const uri = recordingRef.current.getURI();
if (!uri) throw new Error('Recording URI not available after stopping');
const result: RecordingResult = {
uri,
duration,
createdAt: new Date(),
};
recordingRef.current = null;
setStatus('stopped');
return result;
} catch (error) {
console.error('Recording stop error:', error);
setStatus('error');
return null;
}
};
return { status, duration, startRecording, stopRecording };
}Saving Recordings Without Collisions
Audio.Recording.createAsync() writes to a temporary path that gets overwritten on the next recording session. After stopping, immediately move the file to a permanent, timestamped location before doing anything else.
// After calling stopRecording, move the temp file to a stable path
import * as FileSystem from 'expo-file-system';
async function persistRecording(tempUri: string): Promise<string> {
const timestamp = Date.now();
const permanentUri = `${FileSystem.documentDirectory}diary_${timestamp}.m4a`;
await FileSystem.moveAsync({
from: tempUri,
to: permanentUri,
});
return permanentUri;
}Store permanentUri in your database, not the temp path. The temp path becomes invalid within the same session, and is certainly gone after a restart.
Whisper API Transcription — Language Precision and File Handling
The Transcription Service
// services/transcription.ts
import * as FileSystem from 'expo-file-system';
// This endpoint belongs to your server-side worker, not OpenAI directly.
// The worker adds the API key and forwards to OpenAI.
const TRANSCRIPTION_ENDPOINT = 'https://your-worker.workers.dev/transcribe';
interface TranscriptionOptions {
language?: string; // ISO 639-1 code, e.g. 'en', 'ja', 'es'
prompt?: string; // Domain vocabulary hint — improves accuracy significantly
}
interface TranscriptionResult {
text: string;
duration?: number;
detectedLanguage?: string;
}
export async function transcribeAudio(
audioUri: string,
userToken: string,
options: TranscriptionOptions = {}
): Promise<TranscriptionResult> {
const fileInfo = await FileSystem.getInfoAsync(audioUri);
if (!fileInfo.exists) {
throw new Error(`Audio file not found: ${audioUri}`);
}
// Whisper API upper limit is 25 MB per request
if (fileInfo.size && fileInfo.size > 25 * 1024 * 1024) {
throw new Error(
'Recording exceeds 25 MB. This is roughly 3+ hours at high quality — please record shorter entries.'
);
}
const formData = new FormData();
formData.append('audio', {
uri: audioUri,
type: 'audio/m4a',
name: 'entry.m4a',
} as unknown as Blob);
if (options.language) {
formData.append('language', options.language);
}
if (options.prompt) {
formData.append('prompt', options.prompt);
}
const response = await fetch(TRANSCRIPTION_ENDPOINT, {
method: 'POST',
headers: {
Authorization: `Bearer ${userToken}`,
},
body: formData,
});
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
throw new Error(
`Transcription service error (${response.status}): ${errorBody.message ?? 'Unknown error'}`
);
}
const data = await response.json();
return {
text: data.text,
duration: data.duration,
detectedLanguage: data.language,
};
}How the Prompt Parameter Changes Accuracy
The Whisper prompt parameter is documented but underused in most implementations. For a diary app, seeding it with emotion and daily-life vocabulary makes a measurable difference — particularly on words that are phonetically ambiguous or emotionally specific. For an English-language diary app, something like "feelings, today, tired, anxious, excited, meeting, project, conversation" is a reasonable starting prompt. For users who log specific types of entries (work reflection, exercise notes), you could eventually personalize this with their most frequent terms.
In testing, explicit language specification combined with a domain vocabulary prompt improves recognition accuracy on emotionally relevant vocabulary by an estimated 15–25% compared to the bare API call with no configuration.
Claude API Emotion Analysis — Structured JSON Pipeline
Five-Dimension Emotion Model
The simple positive/negative classification that most sentiment APIs return is not meaningful for a daily journaling use case. Human emotional experience is multidimensional: "nervous but energized," "sad but at peace," "frustrated but productive." A five-dimension model captures this complexity and makes the trend visualization genuinely informative.
The five dimensions used here:
- joy: Happiness, contentment, delight (0.0–1.0)
- sadness: Grief, disappointment, melancholy (0.0–1.0)
- anxiety: Worry, stress, uncertainty (0.0–1.0)
- anger: Frustration, irritation, resentment (0.0–1.0)
- energy: Vitality, motivation, engagement (0.0–1.0)
These are not mutually exclusive. A reading of {joy: 0.7, energy: 0.8, anxiety: 0.4} accurately represents an excited but slightly nervous state — something a binary label cannot express.
// services/emotionAnalysis.ts
const ANALYSIS_ENDPOINT = 'https://your-worker.workers.dev/analyze-emotion';
interface EmotionScore {
joy: number;
sadness: number;
anxiety: number;
anger: number;
energy: number;
dominantEmotion: 'joy' | 'sadness' | 'anxiety' | 'anger' | 'energy' | 'neutral';
summary: string; // 1–2 sentence description of the overall emotional state
insight: string; // One notable pattern or detail observed in the text
confidence: number; // 0.0–1.0; lower for very short or emotionally flat transcripts
}
interface EmotionAnalysisResult {
score: EmotionScore;
rawTranscript: string;
analyzedAt: Date;
}
export async function analyzeEmotion(
transcript: string,
userToken: string
): Promise<EmotionAnalysisResult> {
if (!transcript || transcript.trim().length < 15) {
throw new Error('Transcript too short for meaningful emotion analysis (minimum ~15 characters)');
}
const response = await fetch(ANALYSIS_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userToken}`,
},
body: JSON.stringify({ transcript }),
});
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
throw new Error(
`Emotion analysis error (${response.status}): ${errorBody.message ?? 'Unknown error'}`
);
}
const score: EmotionScore = await response.json();
// Always validate — never trust API responses without checking
const numericFields = ['joy', 'sadness', 'anxiety', 'anger', 'energy', 'confidence'] as const;
for (const field of numericFields) {
if (typeof score[field] !== 'number' || score[field] < 0 || score[field] > 1) {
throw new Error(`Invalid emotion score for field "${field}": ${score[field]}`);
}
}
return {
score,
rawTranscript: transcript,
analyzedAt: new Date(),
};
}The Server-Side Claude Prompt
This is what runs inside your Cloudflare Worker or Supabase Edge Function. The system prompt is designed so that Claude Haiku returns clean JSON without preamble, and handles edge cases gracefully.
// worker/handlers/analyzeEmotion.ts (server-side only)
async function callClaudeForEmotionScore(transcript: string, apiKey: string) {
const systemPrompt = `You are an expert in emotional intelligence and psychological analysis.
Analyze the provided diary transcript and respond with ONLY a JSON object — no explanation, no preamble.
Required format:
{
"joy": <0.0–1.0>,
"sadness": <0.0–1.0>,
"anxiety": <0.0–1.0>,
"anger": <0.0–1.0>,
"energy": <0.0–1.0>,
"dominantEmotion": "joy"|"sadness"|"anxiety"|"anger"|"energy"|"neutral",
"summary": "<1–2 sentences describing the overall emotional state in second person>",
"insight": "<1 sentence noting a specific pattern, detail, or contrast in the text>",
"confidence": <0.0–1.0, reduce significantly for very short or ambiguous transcripts>
}
Guidelines:
- Scores are not mutually exclusive. A person can be both anxious and energized.
- "dominantEmotion" should reflect the highest-scoring dimension, or "neutral" if all are below 0.3.
- "summary" should feel empathetic and observational, not clinical.
- "confidence" below 0.4 signals the transcript was too brief for reliable analysis.`;
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-haiku-4-5-20251001',
max_tokens: 600,
system: systemPrompt,
messages: [{ role: 'user', content: `Diary entry:\n\n${transcript}` }],
}),
});
const data = await response.json();
const rawText = data.content?.[0]?.text ?? '';
// Extract JSON robustly — Claude Haiku occasionally adds a brief preamble
const jsonMatch = rawText.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error(`Could not extract JSON from Claude response: ${rawText.slice(0, 300)}`);
}
return JSON.parse(jsonMatch[0]);
}Why Haiku Over Sonnet
For structured extraction tasks where the output schema is fixed, Claude Haiku consistently performs within a few percentage points of Sonnet on accuracy, while being substantially faster and cheaper. For a daily journaling app where users expect near-instant feedback after stopping a recording, Haiku's latency advantage matters. The weekly AI summary feature — where Claude synthesizes seven days of entries into a narrative arc — is the right place to use Sonnet or Opus, where richer synthesis is genuinely beneficial.
Emotional Trend Visualization with Victory Native
The visualization layer is where the app's value proposition becomes tangible. A list of daily scores is data. A line chart that shows anxiety spiking every Monday, or energy recovering reliably on weekends, is insight that motivates continued use. That sustained engagement is what makes the subscription model viable.
// components/EmotionTrendChart.tsx
import {
VictoryChart,
VictoryLine,
VictoryAxis,
VictoryTheme,
VictoryLegend,
} from 'victory-native';
import { View, Text, StyleSheet, Dimensions } from 'react-native';
const SCREEN_WIDTH = Dimensions.get('window').width;
interface DailyEmotionData {
date: string; // 'YYYY-MM-DD'
joy: number;
sadness: number;
anxiety: number;
energy: number;
}
interface EmotionTrendChartProps {
data: DailyEmotionData[];
period: '7days' | '30days';
}
const EMOTION_COLORS = {
joy: '#F59E0B',
sadness: '#6366F1',
anxiety: '#EF4444',
energy: '#10B981',
} as const;
const EMOTION_LABELS: Record<keyof typeof EMOTION_COLORS, string> = {
joy: 'Joy',
sadness: 'Sadness',
anxiety: 'Anxiety',
energy: 'Energy',
};
export function EmotionTrendChart({ data, period }: EmotionTrendChartProps) {
if (data.length < 2) {
return (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>
Record at least two entries to see your emotional trends here.
</Text>
</View>
);
}
const emotions = Object.keys(EMOTION_COLORS) as (keyof typeof EMOTION_COLORS)[];
return (
<View style={styles.container}>
<Text style={styles.title}>
{period === '7days' ? 'Last 7 Days' : 'Last 30 Days'} — Emotional Trends
</Text>
<VictoryChart
width={SCREEN_WIDTH - 32}
height={220}
theme={VictoryTheme.material}
padding={{ top: 12, bottom: 36, left: 36, right: 12 }}
domain={{ y: [0, 1] }}
>
<VictoryAxis
tickCount={Math.min(data.length, period === '7days' ? 7 : 10)}
tickFormat={(_, i) => `${i + 1}`}
style={{ tickLabels: { fontSize: 9, fill: '#6B7280' } }}
/>
<VictoryAxis
dependentAxis
tickFormat={(t) => t.toFixed(1)}
style={{ tickLabels: { fontSize: 9, fill: '#6B7280' } }}
/>
{emotions.map((emotion) => (
<VictoryLine
key={emotion}
data={data.map((d, i) => ({ x: i + 1, y: d[emotion] }))}
style={{
data: { stroke: EMOTION_COLORS[emotion], strokeWidth: 2.5 },
}}
interpolation="catmullRom"
/>
))}
</VictoryChart>
<View style={styles.legendRow}>
{emotions.map((emotion) => (
<View key={emotion} style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: EMOTION_COLORS[emotion] }]} />
<Text style={styles.legendLabel}>{EMOTION_LABELS[emotion]}</Text>
</View>
))}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { marginVertical: 8 },
title: {
fontSize: 14, fontWeight: '600', color: '#374151', marginBottom: 4, paddingHorizontal: 16,
},
emptyContainer: {
height: 120, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 32,
},
emptyText: { fontSize: 13, color: '#9CA3AF', textAlign: 'center', lineHeight: 20 },
legendRow: {
flexDirection: 'row', justifyContent: 'center', flexWrap: 'wrap',
paddingHorizontal: 16, gap: 12, marginTop: 8,
},
legendItem: { flexDirection: 'row', alignItems: 'center', gap: 4 },
legendDot: { width: 8, height: 8, borderRadius: 4 },
legendLabel: { fontSize: 11, color: '#6B7280' },
});One design choice worth mentioning: the catmullRom interpolation mode creates smooth curves between data points rather than sharp angles. For emotional data, smooth curves read more naturally than jagged lines — they feel like they're depicting something organic rather than discrete measurements.
Privacy Architecture — Building Trust With Sensitive Data
Voice diary data is among the most intimate personal data an app can collect. A user speaking alone into their phone, without the self-censorship that comes from knowing others are listening, generates content they would be distressed to see mishandled. Here is how to design for trust.
Never Expose API Keys in the Client
App Store binaries can be decompiled. Any secret embedded in the bundle is not actually secret. Route all third-party API calls through your own backend.
// ❌ This exposes your Anthropic key to anyone who downloads the app
const response = await fetch('https://api.anthropic.com/v1/messages', {
headers: { 'x-api-key': 'sk-ant-api03-...' },
...
});
// ✅ Your worker validates the user's session token and calls Anthropic on their behalf
const response = await fetch('https://your-worker.workers.dev/analyze', {
headers: {
Authorization: `Bearer ${sessionToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ transcript }),
});The Cloudflare Worker pattern is ideal here: zero cold-start latency, global distribution, and environment variables that are never accessible to clients.
Encrypt Emotion Scores in Local Cache
// services/secureStorage.ts
import * as SecureStore from 'expo-secure-store';
const KEY_PREFIX = 'emotion_v1_';
export async function cacheEmotionScore(
date: string,
score: EmotionScore
): Promise<void> {
await SecureStore.setItemAsync(
`${KEY_PREFIX}${date}`,
JSON.stringify(score),
{
// iOS: stored in Keychain, accessible only when device is unlocked
// Android: stored in Keystore, hardware-backed encryption where supported
keychainAccessible: SecureStore.WHEN_UNLOCKED,
}
);
}
export async function getCachedEmotionScore(
date: string
): Promise<EmotionScore | null> {
const raw = await SecureStore.getItemAsync(`${KEY_PREFIX}${date}`);
if (!raw) return null;
try {
return JSON.parse(raw) as EmotionScore;
} catch {
// Corrupted cache entry — remove and return null
await SecureStore.deleteItemAsync(`${KEY_PREFIX}${date}`);
return null;
}
}The local cache serves two purposes: it allows the trend chart to render instantly without a network call, and it provides a fallback if the user is offline. The Supabase record is the source of truth; the secure store is a read-optimized cache.
Give Users Control Over Their Data
In the app settings, expose these controls explicitly:
- Delete all emotion data (local + cloud)
- Export a CSV of all scores
- Opt out of cloud sync (local-only mode)
Users who see these controls in the onboarding flow report significantly higher comfort with the voice recording feature. Data control is not a legal checkbox — it is a trust signal that directly affects whether users feel safe being honest in their recordings.
Common Implementation Pitfalls
These four issues consistently trip up developers building this type of pipeline for the first time.
Pitfall 1: Language Auto-Detection Misfires on Mixed-Language Speech
When a recording contains frequent foreign vocabulary (English technical terms in a Japanese-primary recording, or vice versa), Whisper's auto-detection can misclassify the dominant language, degrading accuracy on the words that matter most. Always specify language explicitly. Without it, accuracy drops 15–30% in real-world mixed-language speech.
Pitfall 2: Claude Returns JSON with Preamble Text
Despite a strict system prompt instructing JSON-only output, Claude Haiku occasionally prepends a brief acknowledgment: "Here is the analysis you requested: {...}". The regex rawText.match(/\{[\s\S]*\}/) extracts the JSON reliably in both cases. Do not assume the response body is pure JSON until a model offers enforced JSON mode that you can verify.
Pitfall 3: Recording Files Are Silently Overwritten
Audio.Recording.createAsync() uses a shared temporary path that gets recycled. Move the completed recording to a timestamped permanent path immediately after stopping, before any other async operation. Failing to do this means every recording overwrites the previous one — a bug that is invisible during single-session testing and only surfaces in real use.
Pitfall 4: API Costs Escalate Without Guardrails
Every Whisper and Claude call has a real cost. In development, this is negligible. In production with active users, unguarded calls become expensive quickly. Build guardrails from the beginning:
- Skip analysis for recordings under 8 seconds (signal quality is too low)
- Batch analysis during a background sync on Wi-Fi rather than immediately after recording
- Free tier cap at 5 analyses per month — this creates a natural, friction-appropriate conversion point
- Log API call counts per user in Supabase so you can identify outliers before they become billing surprises
Subscription Model Design — Where to Draw the Paywall
The boundary between free and paid should be at the point where the user has experienced the core value loop enough to understand what they are paying for — but not so much that they feel no need to pay.
For a voice diary app, recording is the core action and should never be gated. If users feel they might not be able to record a moment, they will delete the app. Transcription is also low enough in cost that a generous monthly limit works well. Analysis is where the paywall makes sense, because it is what converts raw data into insight.
Free Tier
- Recording: unlimited
- Transcription: 15 entries per month
- Emotion analysis: 4 entries per month
- Trend chart: 7-day view only
- Data retention: 30 days
Pro Tier (monthly subscription)
- Transcription and analysis: unlimited
- Trend chart: 7-day, 30-day, 90-day, and yearly views
- Weekly AI summary: Claude synthesizes the week's emotional arc into a narrative paragraph with one personalized observation
- Export: CSV download of all emotion scores
- Data retention: unlimited
The weekly AI summary is the feature that justifies the subscription in user perception. Prompt Claude Sonnet with the week's seven transcript excerpts and emotion scores and ask it to identify the emotional arc, any cyclical patterns, and one constructive reframe. The output — "Your anxiety scores peaked on Tuesday and Wednesday, correlating with transcript content about a work deadline, then dropped sharply Thursday. Your energy levels this week were the highest in the past month on average" — is something the chart communicates visually but the written summary makes emotionally legible in a different way. Users who receive this weekly report consistently cite it in reviews as the feature they would miss most.
For the subscription implementation mechanics, RevenueCat integrates cleanly with Rork-generated Expo code and handles StoreKit 2 / Google Play Billing with minimal boilerplate. The RevenueCat subscription monetization guide covers the full setup including entitlement verification and webhook handling.
Building the App Incrementally — Rork Prompting Strategy
Do not try to build all four layers in a single Rork session. The interdependencies between layers mean that debugging a full-stack issue across recording, transcription, analysis, and visualization simultaneously is extremely difficult.
Session 1: Recording hook only. Goal: start recording, stop recording, see a file in the filesystem with the correct timestamped name. Confirm audio session reset on stop.
Session 2: Add the transcription call. Goal: pass a real recording through your server-side worker and log the transcript to console. Confirm language detection is correct.
Session 3: Add emotion analysis. Goal: pass a real transcript to the Claude worker and log the EmotionScore JSON. Validate score ranges.
Session 4: Add the trend chart. Goal: render synthetic data in Victory Native. Confirm the chart renders correctly with 7 data points.
Session 5: Connect real data. Goal: store real scores in Supabase and render them in the chart.
Session 6: Add subscription gating, privacy controls, and polish.
When something breaks, this staged approach means you know exactly which layer introduced the problem. The instinct to connect everything at once is what leads to spending hours debugging a recording issue that was actually a chart rendering error propagating backwards.
The Insight That Makes It Worth Building
Here is what I found after using this app for three weeks of real entries: the emotional patterns that appeared in the data were ones I had not consciously noticed in daily experience. Anxiety scores elevated reliably on days after poor sleep, even when the transcript mentioned nothing about sleep. Energy scores followed a weekly rhythm that was more predictable than I expected. These are not insights I would have generated from memory or even from reading my own text journal entries — they only became visible when scored consistently and plotted over time.
That is the genuine value proposition of this category of app: not that AI understands your emotions better than you do, but that consistent measurement over time makes patterns visible that short-term introspection cannot. Build something that captures that experience, and users will find their own reasons to keep using it.
Start with one recording today. The pipeline will be worth the implementation effort once you see your own data appear in the chart.