157 lines
4.2 KiB
TypeScript
157 lines
4.2 KiB
TypeScript
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();
|
|
}
|
|
}
|