diff --git a/app/(storybook)/index.tsx b/app/(storybook)/index.tsx
index 5a8897a..51cc72c 100644
--- a/app/(storybook)/index.tsx
+++ b/app/(storybook)/index.tsx
@@ -1,7 +1,7 @@
import { ComponentType } from 'react';
import { Text, View } from 'react-native';
-import { FeatureFlag, logError } from '@/utils';
+import { FeatureFlag, logError } from '@/utils/log';
const StorybookEnabled = process.env.EXPO_PUBLIC_STORYBOOK_ENABLED === 'true';
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 01a0c57..29cbaea 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -35,11 +35,13 @@ import {
import { useTheme, useThemeStore } from '@/theme';
import { FeatureFlag, logError } from '@/utils';
+const StorybookEnabled = process.env.EXPO_PUBLIC_STORYBOOK_ENABLED === 'true';
+
// Prevent the splash screen from auto-hiding before initialization completes
SplashScreen.preventAutoHideAsync();
// Register Android foreground service early (async, fire-and-forget)
-if (Platform.OS === 'android') {
+if (Platform.OS === 'android' && !StorybookEnabled) {
registerForegroundService();
}
@@ -47,7 +49,7 @@ declare const global: {
ErrorUtils?: {
getGlobalHandler: () => (error: Error, isFatal?: boolean) => void;
setGlobalHandler: (
- handler: (error: Error, isFatal?: boolean) => void
+ handler: (error: Error, isFatal?: boolean) => void,
) => void;
};
};
@@ -68,8 +70,6 @@ function installGlobalErrorHandler() {
});
}
-const StorybookEnabled = process.env.EXPO_PUBLIC_STORYBOOK_ENABLED === 'true';
-
export const unstable_settings = {
initialRouteName: StorybookEnabled ? '(storybook)/index' : '(pages)/index',
};
@@ -247,16 +247,16 @@ export default function RootLayout() {
useEffect(() => {
async function initializeApp() {
try {
- // Initialize in parallel where possible
- await Promise.all([
- initTheme(),
- initializeSettingsStore(),
- initializeSessionStore(),
- storageService.processPendingDeletes(),
- ]);
-
- // Initialize transcription store (depends on session store being ready)
- await initializeTranscriptionStore();
+ await initTheme();
+
+ if (!StorybookEnabled) {
+ await Promise.all([
+ initializeSettingsStore(),
+ initializeSessionStore(),
+ storageService.processPendingDeletes(),
+ ]);
+ await initializeTranscriptionStore();
+ }
setAppReady(true);
} catch (error) {
@@ -264,8 +264,6 @@ export default function RootLayout() {
flag: FeatureFlag.general,
message: 'Failed to initialize app',
});
- // Still mark as ready to allow the app to render
- // Individual stores handle their own error states
setAppReady(true);
}
}
diff --git a/components/shared/recording-controls/RecordingControlsView.stories.tsx b/components/shared/recording-controls/RecordingControlsView.stories.tsx
deleted file mode 100644
index 62e9617..0000000
--- a/components/shared/recording-controls/RecordingControlsView.stories.tsx
+++ /dev/null
@@ -1,152 +0,0 @@
-import type { Meta, StoryObj } from '@storybook/react-native';
-import { ComponentProps, ReactNode, useEffect, useState } from 'react';
-import { View } from 'react-native';
-
-import { RecordingControlsView } from '@/components';
-import { TranscriptionState } from '@/models';
-import { useTranscriptionStore } from '@/stores';
-import { useTheme } from '@/theme';
-
-const StoryContainer = ({ children }: { children: ReactNode }) => {
- const { theme } = useTheme();
- return (
-
- {children}
-
- );
-};
-
-const RecordingControlsViewMeta: Meta = {
- title: 'Shared Components/RecordingControlsView',
- component: RecordingControlsView,
- argTypes: {
- state: {
- control: 'select',
- options: [
- TranscriptionState.READY,
- TranscriptionState.RECORDING,
- TranscriptionState.TRANSCRIBING,
- TranscriptionState.LOADING,
- ],
- },
- enabled: {
- control: 'boolean',
- },
- },
- decorators: [
- (Story) => (
-
-
-
- ),
- ],
-};
-
-export default RecordingControlsViewMeta;
-
-type Story = StoryObj;
-
-const DynamicRecordingControlsView = (
- props: Omit, 'colors'>
-) => {
- const { theme } = useTheme();
- return ;
-};
-
-export const Ready: Story = {
- render: () => (
- console.log('Recording started')}
- />
- ),
-};
-
-export const Recording: Story = {
- render: () => (
- console.log('Recording stopped')}
- />
- ),
-};
-
-export const RecordingLowAudio: Story = {
- render: () => (
-
- ),
-};
-
-export const RecordingHighAudio: Story = {
- render: () => (
-
- ),
-};
-
-export const Transcribing: Story = {
- render: () => (
-
- ),
-};
-
-export const Interactive = () => {
- const [state, setState] = useState(TranscriptionState.READY);
- const { theme } = useTheme();
- const updateAudioLevel = useTranscriptionStore((s) => s.updateAudioLevel);
-
- useEffect(() => {
- let interval: ReturnType | null = null;
- if (state === TranscriptionState.RECORDING) {
- interval = setInterval(() => {
- updateAudioLevel(Math.random());
- }, 100);
- } else {
- updateAudioLevel(0);
- }
- return () => {
- if (interval) clearInterval(interval);
- };
- }, [state, updateAudioLevel]);
-
- const handleStart = () => {
- setState(TranscriptionState.RECORDING);
- setTimeout(() => {
- setState(TranscriptionState.TRANSCRIBING);
- setTimeout(() => {
- setState(TranscriptionState.READY);
- }, 2000);
- }, 5000);
- };
-
- return (
-
- {}}
- />
-
- );
-};
diff --git a/services/AudioService.ts b/services/AudioService.ts
index 1587a21..7ea16f2 100644
--- a/services/AudioService.ts
+++ b/services/AudioService.ts
@@ -1,6 +1,5 @@
import { EventEmitter } from 'events';
-import AudioRecord from '@fugood/react-native-audio-pcm-stream';
import {
AudioModule,
AudioRecorder,
@@ -22,6 +21,12 @@ import {
import { permissionService } from './PermissionService';
+let AudioRecord: any;
+try {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ AudioRecord = require('@fugood/react-native-audio-pcm-stream').default;
+} catch {}
+
const RECORDING_OPTIONS: RecordingOptions = {
extension: '.wav',
sampleRate: AppConstants.AUDIO_SAMPLE_RATE,
diff --git a/services/EncryptionService.ts b/services/EncryptionService.ts
index 0931e53..5828f10 100644
--- a/services/EncryptionService.ts
+++ b/services/EncryptionService.ts
@@ -1,7 +1,12 @@
import { fromByteArray } from 'base64-js';
import * as Crypto from 'expo-crypto';
import * as SecureStore from 'expo-secure-store';
-import AesGcmCrypto from 'react-native-aes-gcm-crypto';
+
+let AesGcmCrypto: any;
+try {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ AesGcmCrypto = require('react-native-aes-gcm-crypto').default;
+} catch {}
const KEY_STORAGE_KEY = 'aes_data_key';
@@ -20,7 +25,7 @@ const createEncryptionService = () => {
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(
- `Failed to retrieve or generate encryption key: ${message}`
+ `Failed to retrieve or generate encryption key: ${message}`,
);
}
};
@@ -43,7 +48,7 @@ const createEncryptionService = () => {
if (parts.length !== 2) {
throw new Error(
- 'Invalid cipher text format. Expected format: iv:contenttag'
+ 'Invalid cipher text format. Expected format: iv:contenttag',
);
}
@@ -58,7 +63,7 @@ const createEncryptionService = () => {
key,
iv,
tag,
- false
+ false,
);
return decrypted;
diff --git a/services/WhisperService.ts b/services/WhisperService.ts
index f866b6d..84c3395 100644
--- a/services/WhisperService.ts
+++ b/services/WhisperService.ts
@@ -1,24 +1,42 @@
import { Asset } from 'expo-asset';
import { File } from 'expo-file-system';
import { Platform } from 'react-native';
-import RNFS from 'react-native-fs';
-import {
- initWhisper,
- initWhisperVad,
- type WhisperContext,
- type WhisperVadContext,
-} from 'whisper.rn';
-import {
- RealtimeTranscriber,
- type AudioStreamData,
+import type { WhisperContext, WhisperVadContext } from 'whisper.rn';
+import type {
+ AudioStreamData,
+ RealtimeTranscriber as RealtimeTranscriberType,
} from 'whisper.rn/src/realtime-transcription';
-import { AudioPcmStreamAdapter } from 'whisper.rn/src/realtime-transcription/adapters/AudioPcmStreamAdapter';
+import type { AudioPcmStreamAdapter as AudioPcmStreamAdapterType } from 'whisper.rn/src/realtime-transcription/adapters/AudioPcmStreamAdapter';
import { FeatureFlag, logError, logWarn } from '@/utils';
import { audioService } from './AudioService';
import { audioSessionService } from './AudioSessionService';
+/* eslint-disable @typescript-eslint/no-require-imports */
+let RNFS: any;
+try {
+ RNFS = require('react-native-fs').default;
+} catch {}
+let initWhisper: any;
+try {
+ initWhisper = require('whisper.rn').initWhisper;
+} catch {}
+let initWhisperVad: any;
+try {
+ initWhisperVad = require('whisper.rn').initWhisperVad;
+} catch {}
+let RealtimeTranscriber: any;
+try {
+ RealtimeTranscriber =
+ require('whisper.rn/src/realtime-transcription').RealtimeTranscriber;
+} catch {}
+let AudioPcmStreamAdapter: any;
+try {
+ AudioPcmStreamAdapter =
+ require('whisper.rn/src/realtime-transcription/adapters/AudioPcmStreamAdapter').AudioPcmStreamAdapter;
+} catch {}
+
const IOS_INIT_THREADS = 2;
const IOS_VAD_THREADS = 1;
@@ -31,16 +49,14 @@ interface RealtimeTranscribeEvent {
recordingTime: number;
}
-// eslint-disable-next-line @typescript-eslint/no-require-imports
const modelAsset = require('@/assets/models/whisper/ggml-tiny.bin');
-// eslint-disable-next-line @typescript-eslint/no-require-imports
const vadModelAsset = require('@/assets/models/whisper/ggml-silero-v6.2.0.bin');
interface WhisperServiceState {
whisperContext: WhisperContext | null;
vadContext: WhisperVadContext | null;
- realtimeTranscriber: RealtimeTranscriber | null;
- realtimeAudioStream: AudioPcmStreamAdapter | null;
+ realtimeTranscriber: RealtimeTranscriberType | null;
+ realtimeAudioStream: AudioPcmStreamAdapterType | null;
isInitialized: boolean;
isInitializing: boolean;
isTranscribing: boolean;
@@ -116,7 +132,7 @@ const createWhisperService = () => {
const baseAlpha = rising ? 0.92 : 0.7;
const alpha = Math.max(
0.6,
- Math.min(rising ? 0.95 : 0.8, baseAlpha * (dtMs / 16.0))
+ Math.min(rising ? 0.95 : 0.8, baseAlpha * (dtMs / 16.0)),
);
smoothedLevel = smoothedLevel + (level - smoothedLevel) * alpha;
@@ -222,7 +238,7 @@ const createWhisperService = () => {
const transcribeFile = async (
audioPath: string,
languageCode?: string,
- prompt?: string
+ prompt?: string,
): Promise => {
if (!state.isInitialized || !state.whisperContext) {
throw new Error('Whisper service not initialized');
@@ -260,7 +276,7 @@ const createWhisperService = () => {
const startRealtimeTranscription = async (
languageCode?: string,
- prompt?: string
+ prompt?: string,
): Promise => {
if (!state.isInitialized || !state.whisperContext || !state.vadContext) {
logError('Cannot start real-time: Whisper or VAD not initialized', {
@@ -292,7 +308,7 @@ const createWhisperService = () => {
if (!warmUpSuccess) {
logError(
'iOS audio warm-up failed, cannot start real-time transcription',
- { flag: FeatureFlag.transcription }
+ { flag: FeatureFlag.transcription },
);
return false;
}
@@ -357,7 +373,7 @@ const createWhisperService = () => {
transcribeEvent,
}: {
transcribeEvent: RealtimeTranscribeEvent;
- }) => transcribeEvent.data?.result?.trim()
+ }) => transcribeEvent.data?.result?.trim(),
)
.filter(Boolean)
.join(' ');
@@ -392,7 +408,7 @@ const createWhisperService = () => {
onStatusChange: (isActive: boolean) => {
state.isRealtimeRecording = isActive;
},
- }
+ },
);
state.realtimeTranscriber = transcriber;
@@ -428,7 +444,7 @@ const createWhisperService = () => {
const finalText = allResults
.map(
({ transcribeEvent }: { transcribeEvent: RealtimeTranscribeEvent }) =>
- transcribeEvent.data?.result?.trim()
+ transcribeEvent.data?.result?.trim(),
)
.filter(Boolean)
.join(' ');
@@ -472,7 +488,7 @@ const createWhisperService = () => {
};
const subscribeToPartialResults = (
- callback: (text: string) => void
+ callback: (text: string) => void,
): (() => void) => {
state.partialCallbacks.add(callback);
@@ -482,7 +498,7 @@ const createWhisperService = () => {
};
const subscribeToAudioLevel = (
- callback: (level: number) => void
+ callback: (level: number) => void,
): (() => void) => {
state.audioLevelCallbacks.add(callback);
diff --git a/utils/WavWriter.ts b/utils/WavWriter.ts
index e7c91df..00b4573 100644
--- a/utils/WavWriter.ts
+++ b/utils/WavWriter.ts
@@ -1,14 +1,18 @@
-import RNFS from 'react-native-fs';
-
import { FeatureFlag, logError } from './log';
+let RNFS: any;
+try {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ RNFS = require('react-native-fs').default;
+} catch {}
+
const WAV_HEADER_SIZE = 44;
const createWavHeaderBuffer = (
dataLength: number,
sampleRate: number,
numChannels: number,
- bitsPerSample: number
+ bitsPerSample: number,
): string => {
const byteRate = (sampleRate * numChannels * bitsPerSample) / 8;
const blockAlign = (numChannels * bitsPerSample) / 8;
@@ -67,7 +71,7 @@ export const createPcmStreamWriter = (
outputPath: string,
sampleRate: number,
numChannels: number,
- bitsPerSample: number = 16
+ bitsPerSample: number = 16,
): PcmStreamWriter => {
const tempPath = `${outputPath}.pcm`;
let totalBytes = 0;
@@ -125,7 +129,7 @@ export const createPcmStreamWriter = (
totalBytes,
sampleRate,
numChannels,
- bitsPerSample
+ bitsPerSample,
);
await RNFS.writeFile(outputPath, wavHeader, 'base64');
const pcmData = await RNFS.readFile(tempPath, 'base64');