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');