import { Platform } from 'react-native'; import { AudioModule, type RecordingOptions, RecordingPresets, } from 'expo-audio'; import { audioFocus } from '@/core/audio/audio-focus'; import type { RecorderStatus } from './types'; // Native module exposes AudioRecorder as constructor; ESLint import/namespace doesn't resolve it // eslint-disable-next-line import/namespace -- AudioModule.AudioRecorder exists at runtime const AudioRecorderCtor = AudioModule.AudioRecorder; type StatusListener = (status: RecorderStatus) => void; type RecordingCompleteListener = (uri: string, durationMs: number) => void; /** Platform-specific recording options (expo-audio internal createRecordingOptions logic). */ function createRecordingOptions( options: RecordingOptions, ): Partial { const common = { extension: options.extension, sampleRate: options.sampleRate, numberOfChannels: options.numberOfChannels, bitRate: options.bitRate, isMeteringEnabled: options.isMeteringEnabled ?? false, }; if (Platform.OS === 'ios') return { ...common, ...options.ios }; if (Platform.OS === 'android') return { ...common, ...options.android }; return { ...common, ...options.web }; } /** * Class-level wrapper over expo-audio recording. * No React dependency — hooks/ layer adapts this to React. */ interface RecorderInstance { prepareToRecordAsync(options?: Partial): Promise; record(): void; stop(): Promise; uri: string | null; } export class VoiceRecorder { private recorder: RecorderInstance | null = null; private status: RecorderStatus = 'idle'; private startTime = 0; private statusListeners = new Set(); private completeListeners = new Set(); async requestPermission(): Promise { const result = await AudioModule.requestRecordingPermissionsAsync(); return result.granted; } async start(): Promise { if (this.status !== 'idle') return false; const hasPermission = await this.requestPermission(); if (!hasPermission) return false; const acquired = await audioFocus.acquireForRecording(); if (!acquired) return false; this.setStatus('preparing'); try { this.recorder = new AudioRecorderCtor( createRecordingOptions(RecordingPresets.HIGH_QUALITY), ); await this.recorder.prepareToRecordAsync(); this.recorder.record(); this.startTime = Date.now(); this.setStatus('recording'); return true; } catch { await this.cleanup(); return false; } } async stop(): Promise<{ uri: string; durationMs: number } | null> { if (!this.recorder || this.status !== 'recording') { return null; } this.setStatus('stopping'); try { await this.recorder.stop(); const uri = this.recorder.uri; const durationMs = Date.now() - this.startTime; if (uri) { for (const listener of this.completeListeners) { listener(uri, durationMs); } } await this.cleanup(); return uri ? { uri, durationMs } : null; } catch { await this.cleanup(); return null; } } async cancel(): Promise { if (this.recorder) { try { await this.recorder.stop(); } catch { // Already stopped or failed } } await this.cleanup(); } getStatus(): RecorderStatus { return this.status; } getDurationMs(): number { if (this.status === 'recording' || this.status === 'paused') { return Date.now() - this.startTime; } return 0; } onStatusChange(listener: StatusListener): () => void { this.statusListeners.add(listener); return () => this.statusListeners.delete(listener); } onRecordingComplete(listener: RecordingCompleteListener): () => void { this.completeListeners.add(listener); return () => this.completeListeners.delete(listener); } private setStatus(next: RecorderStatus): void { if (this.status === next) return; this.status = next; for (const listener of this.statusListeners) { listener(next); } } private async cleanup(): Promise { this.recorder = null; this.startTime = 0; this.setStatus('idle'); await audioFocus.release(); } }