Files
life-echo/app-expo/src/features/voice/recorder.ts

157 lines
4.2 KiB
TypeScript
Raw Normal View History

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<RecordingOptions> {
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<RecordingOptions>): Promise<void>;
record(): void;
stop(): Promise<void>;
uri: string | null;
}
export class VoiceRecorder {
private recorder: RecorderInstance | null = null;
private status: RecorderStatus = 'idle';
private startTime = 0;
private statusListeners = new Set<StatusListener>();
private completeListeners = new Set<RecordingCompleteListener>();
async requestPermission(): Promise<boolean> {
const result = await AudioModule.requestRecordingPermissionsAsync();
return result.granted;
}
async start(): Promise<boolean> {
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<void> {
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<void> {
this.recorder = null;
this.startTime = 0;
this.setStatus('idle');
await audioFocus.release();
}
}