fix(mobile-recorder): scope recorder control to its owner and keep Android recordings on stop failure
This commit is contained in:
parent
72c4ddaefb
commit
ace946d5c7
10 changed files with 1673 additions and 697 deletions
|
|
@ -1,676 +1,148 @@
|
|||
import { NativeModules, PermissionsAndroid, Platform } from 'react-native';
|
||||
import { Dirs, FileSystem } from 'react-native-file-access';
|
||||
import {
|
||||
AudioEncoderAndroidType,
|
||||
AudioSourceAndroidType,
|
||||
AVEncoderAudioQualityIOSType,
|
||||
OutputFormatAndroidType,
|
||||
createSound,
|
||||
type AudioSet,
|
||||
type RecordBackType,
|
||||
} from 'react-native-nitro-sound';
|
||||
import { createUuidV4 } from './random-id';
|
||||
import { Platform } from 'react-native';
|
||||
import { AndroidForegroundRecorderBackend } from './recorder/android-foreground-backend';
|
||||
import { NitroRecorderBackend } from './recorder/nitro-backend';
|
||||
import { RecorderBusyError } from './recorder/recorder-errors';
|
||||
import type {
|
||||
RecordedAudio,
|
||||
RecorderBackend,
|
||||
RecorderOwner,
|
||||
RecorderSession,
|
||||
RecordingProgressHandler,
|
||||
RecordingStateHandler,
|
||||
StartRecordingOptions,
|
||||
} from './recorder/recorder-types';
|
||||
|
||||
const RECORDING_PREFIX = 'd3ro-recording-';
|
||||
const RECORDING_EXTENSION = '.m4a';
|
||||
const RECORDING_MIME_TYPE = 'audio/mp4';
|
||||
const ANDROID_RECORDING_MIME_TYPE = 'audio/wav';
|
||||
export type {
|
||||
RecordedAudio,
|
||||
RecorderBackend,
|
||||
RecorderOwner,
|
||||
RecorderSession,
|
||||
RecordingProgress,
|
||||
RecordingRuntimeSnapshot,
|
||||
RecordingRuntimeState,
|
||||
StartRecordingOptions,
|
||||
} from './recorder/recorder-types';
|
||||
export { RecorderBusyError } from './recorder/recorder-errors';
|
||||
|
||||
const AUDIO_SETTINGS: AudioSet = {
|
||||
AudioSourceAndroid: AudioSourceAndroidType.MIC,
|
||||
OutputFormatAndroid: OutputFormatAndroidType.MPEG_4,
|
||||
AudioEncoderAndroid: AudioEncoderAndroidType.AAC,
|
||||
AudioQuality: 'medium',
|
||||
AudioChannels: 1,
|
||||
AudioSamplingRate: 44100,
|
||||
AudioEncodingBitRate: 128000,
|
||||
AVFormatIDKeyIOS: 'aac',
|
||||
AVEncoderAudioQualityKeyIOS: AVEncoderAudioQualityIOSType.high,
|
||||
AVNumberOfChannelsKeyIOS: 1,
|
||||
AVSampleRateKeyIOS: 44100,
|
||||
};
|
||||
/**
|
||||
* A capture found with no lease (the Android foreground service survives a JS
|
||||
* restart) can only have come from a meeting recording.
|
||||
*/
|
||||
const UNLEASED_CAPTURE_OWNER: RecorderOwner = 'record';
|
||||
|
||||
type NativeRecorderState =
|
||||
| 'idle'
|
||||
| 'starting'
|
||||
| 'recording'
|
||||
| 'pausing'
|
||||
| 'paused'
|
||||
| 'resuming'
|
||||
| 'stopping'
|
||||
| 'stopped';
|
||||
|
||||
export interface RecordingProgress {
|
||||
durationMs: number;
|
||||
meteringDb: number | null;
|
||||
}
|
||||
|
||||
export interface RecordedAudio {
|
||||
uri: string;
|
||||
path: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export type RecordingRuntimeState =
|
||||
| 'idle'
|
||||
| 'starting'
|
||||
| 'recording'
|
||||
| 'paused'
|
||||
| 'stopped'
|
||||
| 'recoverable';
|
||||
|
||||
export interface RecordingRuntimeSnapshot {
|
||||
state: RecordingRuntimeState;
|
||||
recording: RecordedAudio | null;
|
||||
meetingId: string | null;
|
||||
interruptionReason: string | null;
|
||||
startedAtMs: number;
|
||||
}
|
||||
|
||||
export interface StartRecordingOptions {
|
||||
meetingId?: string;
|
||||
onStateChange?: (snapshot: RecordingRuntimeSnapshot) => void;
|
||||
}
|
||||
|
||||
interface NativeRecordingSnapshot {
|
||||
state: RecordingRuntimeState;
|
||||
path: string | null;
|
||||
uri: string | null;
|
||||
fileName: string | null;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
durationMs: number;
|
||||
meteringDb: number | null;
|
||||
meetingId: string | null;
|
||||
interruptionReason: string | null;
|
||||
startedAtMs: number;
|
||||
recoverable: boolean;
|
||||
}
|
||||
|
||||
interface D3RORecordingNativeModule {
|
||||
getStatus: () => Promise<NativeRecordingSnapshot>;
|
||||
start: (options: { meetingId?: string }) => Promise<NativeRecordingSnapshot>;
|
||||
pause: () => Promise<NativeRecordingSnapshot>;
|
||||
resume: () => Promise<NativeRecordingSnapshot>;
|
||||
stop: () => Promise<NativeRecordingSnapshot>;
|
||||
cancel: () => Promise<NativeRecordingSnapshot>;
|
||||
dispose: (path: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function androidRecordingModule(): D3RORecordingNativeModule {
|
||||
const candidate = NativeModules.D3RORecording as
|
||||
| Partial<D3RORecordingNativeModule>
|
||||
| undefined;
|
||||
if (
|
||||
candidate === undefined ||
|
||||
typeof candidate.getStatus !== 'function' ||
|
||||
typeof candidate.start !== 'function' ||
|
||||
typeof candidate.pause !== 'function' ||
|
||||
typeof candidate.resume !== 'function' ||
|
||||
typeof candidate.stop !== 'function' ||
|
||||
typeof candidate.cancel !== 'function' ||
|
||||
typeof candidate.dispose !== 'function'
|
||||
) {
|
||||
throw new Error('Android foreground recording module is unavailable');
|
||||
}
|
||||
return candidate as D3RORecordingNativeModule;
|
||||
}
|
||||
|
||||
function assertFiniteNonNegative(value: unknown, field: string): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`Android recorder returned invalid ${field}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseNativeSnapshot(value: NativeRecordingSnapshot): RecordingRuntimeSnapshot {
|
||||
const states: readonly RecordingRuntimeState[] = [
|
||||
'idle',
|
||||
'starting',
|
||||
'recording',
|
||||
'paused',
|
||||
'stopped',
|
||||
'recoverable',
|
||||
];
|
||||
if (!states.includes(value.state)) {
|
||||
throw new Error('Android recorder returned an invalid state');
|
||||
}
|
||||
const durationMs = assertFiniteNonNegative(value.durationMs, 'duration');
|
||||
const size = assertFiniteNonNegative(value.size, 'file size');
|
||||
const startedAtMs = assertFiniteNonNegative(value.startedAtMs, 'start time');
|
||||
const hasFile = value.path !== null || value.uri !== null || value.fileName !== null;
|
||||
if (
|
||||
hasFile &&
|
||||
(typeof value.path !== 'string' ||
|
||||
value.path.length === 0 ||
|
||||
value.uri !== `file://${value.path}` ||
|
||||
typeof value.fileName !== 'string' ||
|
||||
!value.fileName.endsWith('.wav') ||
|
||||
value.mimeType !== ANDROID_RECORDING_MIME_TYPE)
|
||||
) {
|
||||
throw new Error('Android recorder returned an invalid recording file');
|
||||
}
|
||||
if (value.state !== 'idle' && !hasFile) {
|
||||
throw new Error('Android recorder state has no recording file');
|
||||
}
|
||||
const recording = hasFile
|
||||
? {
|
||||
uri: value.uri as string,
|
||||
path: value.path as string,
|
||||
fileName: value.fileName as string,
|
||||
mimeType: ANDROID_RECORDING_MIME_TYPE,
|
||||
size,
|
||||
durationMs,
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
state: value.state,
|
||||
recording,
|
||||
meetingId: typeof value.meetingId === 'string' ? value.meetingId : null,
|
||||
interruptionReason:
|
||||
typeof value.interruptionReason === 'string'
|
||||
? value.interruptionReason
|
||||
: null,
|
||||
startedAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
function createTemporaryPath(): string {
|
||||
return `${Dirs.CacheDir}/${RECORDING_PREFIX}${createUuidV4()}${RECORDING_EXTENSION}`;
|
||||
}
|
||||
|
||||
function pathFromFileUri(uri: string): string | null {
|
||||
if (!uri.startsWith('file://')) return null;
|
||||
|
||||
try {
|
||||
return decodeURIComponent(uri.slice('file://'.length));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isOwnedTemporaryPath(path: string): boolean {
|
||||
const cachePrefix = `${Dirs.CacheDir}/`;
|
||||
if (!path.startsWith(cachePrefix)) return false;
|
||||
|
||||
const fileName = path.slice(cachePrefix.length);
|
||||
return (
|
||||
!fileName.includes('/') &&
|
||||
!fileName.includes('\\') &&
|
||||
fileName.startsWith(RECORDING_PREFIX) &&
|
||||
fileName.endsWith(RECORDING_EXTENSION)
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteOwnedTemporaryFile(path: string | null): Promise<void> {
|
||||
if (path === null) return;
|
||||
if (!isOwnedTemporaryPath(path)) {
|
||||
throw new Error('Refusing to delete a recording outside the app cache');
|
||||
}
|
||||
|
||||
if (await FileSystem.exists(path)) {
|
||||
await FileSystem.unlink(path);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteStaleTemporaryFiles(): Promise<void> {
|
||||
const entries = await FileSystem.ls(Dirs.CacheDir);
|
||||
const stalePaths = entries
|
||||
.filter(
|
||||
entry =>
|
||||
entry.startsWith(RECORDING_PREFIX) &&
|
||||
entry.endsWith(RECORDING_EXTENSION),
|
||||
)
|
||||
.map(entry => `${Dirs.CacheDir}/${entry}`);
|
||||
|
||||
await Promise.all(stalePaths.map(deleteOwnedTemporaryFile));
|
||||
export function createPlatformRecorderBackend(): RecorderBackend {
|
||||
return Platform.OS === 'android'
|
||||
? new AndroidForegroundRecorderBackend()
|
||||
: new NitroRecorderBackend();
|
||||
}
|
||||
|
||||
/**
|
||||
* Facade over the platform capture backend. Screens never drive the backend
|
||||
* directly: they acquire an owner-scoped {@link RecorderSession}, so a screen
|
||||
* can only stop or discard a capture it holds.
|
||||
*/
|
||||
export class AudioRecorder {
|
||||
private state: NativeRecorderState = 'idle';
|
||||
private currentPath: string | null = null;
|
||||
private progress: RecordingProgress = { durationMs: 0, meteringDb: null };
|
||||
private pendingStart: Promise<void> | null = null;
|
||||
private pendingTransition: Promise<void> | null = null;
|
||||
private pendingStop: Promise<RecordedAudio> | null = null;
|
||||
private sound: ReturnType<typeof createSound> | null = null;
|
||||
private progressHandler: ((progress: RecordingProgress) => void) | null =
|
||||
null;
|
||||
private stateHandler: ((snapshot: RecordingRuntimeSnapshot) => void) | null =
|
||||
null;
|
||||
private androidPollGeneration = 0;
|
||||
private selectedBackend: RecorderBackend | null;
|
||||
private lease: RecorderOwner | null = null;
|
||||
|
||||
private getSound(): ReturnType<typeof createSound> {
|
||||
if (this.sound !== null) return this.sound;
|
||||
|
||||
const sound = createSound();
|
||||
sound.setSubscriptionDuration(0.1);
|
||||
sound.addRecordBackListener((event: RecordBackType) => {
|
||||
if (this.progressHandler === null) return;
|
||||
|
||||
const durationMs = Number.isFinite(event.currentPosition)
|
||||
? Math.max(0, Math.round(event.currentPosition))
|
||||
: this.progress.durationMs;
|
||||
const meteringDb =
|
||||
typeof event.currentMetering === 'number' &&
|
||||
Number.isFinite(event.currentMetering)
|
||||
? event.currentMetering
|
||||
: null;
|
||||
|
||||
this.progress = { durationMs, meteringDb };
|
||||
this.progressHandler(this.progress);
|
||||
});
|
||||
this.sound = sound;
|
||||
return sound;
|
||||
constructor(backend?: RecorderBackend) {
|
||||
this.selectedBackend = backend ?? null;
|
||||
}
|
||||
|
||||
async requestPermission(): Promise<boolean> {
|
||||
if (Platform.OS !== 'android') {
|
||||
// Nitro Sound requests iOS microphone access through AVAudioSession.
|
||||
return true;
|
||||
private get backend(): RecorderBackend {
|
||||
if (this.selectedBackend === null) {
|
||||
this.selectedBackend = createPlatformRecorderBackend();
|
||||
}
|
||||
|
||||
const permission = PermissionsAndroid.PERMISSIONS.RECORD_AUDIO;
|
||||
if (await PermissionsAndroid.check(permission)) return true;
|
||||
|
||||
const result = await PermissionsAndroid.request(permission, {
|
||||
title: 'Microphone access',
|
||||
message:
|
||||
'D3RO Voice needs microphone access to record and transcribe audio.',
|
||||
buttonPositive: 'Allow',
|
||||
buttonNegative: 'Cancel',
|
||||
});
|
||||
|
||||
return result === PermissionsAndroid.RESULTS.GRANTED;
|
||||
return this.selectedBackend;
|
||||
}
|
||||
|
||||
async start(
|
||||
onProgress: (progress: RecordingProgress) => void,
|
||||
options: StartRecordingOptions = {},
|
||||
): Promise<void> {
|
||||
if (this.state !== 'idle') {
|
||||
throw new Error(`Cannot start recorder while it is ${this.state}`);
|
||||
}
|
||||
|
||||
this.state = 'starting';
|
||||
if (Platform.OS === 'android') {
|
||||
this.progressHandler = onProgress;
|
||||
this.stateHandler = options.onStateChange ?? null;
|
||||
try {
|
||||
const snapshot = parseNativeSnapshot(
|
||||
await androidRecordingModule().start(
|
||||
options.meetingId === undefined
|
||||
? {}
|
||||
: { meetingId: options.meetingId },
|
||||
),
|
||||
);
|
||||
if (snapshot.state !== 'recording' || snapshot.recording === null) {
|
||||
throw new Error('Android foreground recorder did not start');
|
||||
}
|
||||
this.currentPath = snapshot.recording.path;
|
||||
this.progress = {
|
||||
durationMs: snapshot.recording.durationMs,
|
||||
meteringDb: null,
|
||||
};
|
||||
this.state = 'recording';
|
||||
this.stateHandler?.(snapshot);
|
||||
this.startAndroidPolling();
|
||||
return;
|
||||
} catch (error) {
|
||||
this.progressHandler = null;
|
||||
this.stateHandler = null;
|
||||
this.currentPath = null;
|
||||
this.state = 'idle';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await deleteStaleTemporaryFiles();
|
||||
} catch (error) {
|
||||
this.state = 'idle';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const outputPath = createTemporaryPath();
|
||||
this.currentPath = outputPath;
|
||||
this.progress = { durationMs: 0, meteringDb: null };
|
||||
this.progressHandler = onProgress;
|
||||
const sound = this.getSound();
|
||||
|
||||
const pendingStart = (async () => {
|
||||
try {
|
||||
const startedUri = await sound.startRecorder(
|
||||
outputPath,
|
||||
AUDIO_SETTINGS,
|
||||
true,
|
||||
);
|
||||
const startedPath = pathFromFileUri(startedUri);
|
||||
|
||||
if (startedPath !== outputPath) {
|
||||
throw new Error('Recorder returned an unexpected output URI');
|
||||
}
|
||||
|
||||
this.state = 'recording';
|
||||
} catch (error) {
|
||||
try {
|
||||
await sound.stopRecorder();
|
||||
} catch {
|
||||
// stopRecorder also releases Android's MediaRecorder when stopping fails.
|
||||
}
|
||||
this.progressHandler = null;
|
||||
if (this.currentPath === outputPath) this.currentPath = null;
|
||||
this.state = 'idle';
|
||||
await deleteOwnedTemporaryFile(outputPath);
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
this.pendingStart = pendingStart;
|
||||
try {
|
||||
await pendingStart;
|
||||
} finally {
|
||||
if (this.pendingStart === pendingStart) this.pendingStart = null;
|
||||
}
|
||||
requestPermission(): Promise<boolean> {
|
||||
return this.backend.requestPermission();
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
if (this.state !== 'recording') {
|
||||
throw new Error(`Cannot pause recorder while it is ${this.state}`);
|
||||
}
|
||||
|
||||
this.state = 'pausing';
|
||||
if (Platform.OS === 'android') {
|
||||
try {
|
||||
const snapshot = parseNativeSnapshot(await androidRecordingModule().pause());
|
||||
if (snapshot.state !== 'paused') throw new Error('Android recorder did not pause');
|
||||
this.state = 'paused';
|
||||
this.stateHandler?.(snapshot);
|
||||
return;
|
||||
} catch (error) {
|
||||
this.state = 'recording';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const pendingTransition = (async () => {
|
||||
try {
|
||||
await this.getSound().pauseRecorder();
|
||||
this.state = 'paused';
|
||||
} catch (error) {
|
||||
this.state = 'recording';
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
this.pendingTransition = pendingTransition;
|
||||
try {
|
||||
await pendingTransition;
|
||||
} finally {
|
||||
if (this.pendingTransition === pendingTransition) {
|
||||
this.pendingTransition = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async resume(): Promise<void> {
|
||||
if (this.state !== 'paused') {
|
||||
throw new Error(`Cannot resume recorder while it is ${this.state}`);
|
||||
}
|
||||
|
||||
this.state = 'resuming';
|
||||
if (Platform.OS === 'android') {
|
||||
try {
|
||||
const snapshot = parseNativeSnapshot(await androidRecordingModule().resume());
|
||||
if (snapshot.state !== 'recording') throw new Error('Android recorder did not resume');
|
||||
this.state = 'recording';
|
||||
this.stateHandler?.(snapshot);
|
||||
return;
|
||||
} catch (error) {
|
||||
this.state = 'paused';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const pendingTransition = (async () => {
|
||||
try {
|
||||
await this.getSound().resumeRecorder();
|
||||
this.state = 'recording';
|
||||
} catch (error) {
|
||||
this.state = 'paused';
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
this.pendingTransition = pendingTransition;
|
||||
try {
|
||||
await pendingTransition;
|
||||
} finally {
|
||||
if (this.pendingTransition === pendingTransition) {
|
||||
this.pendingTransition = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<RecordedAudio> {
|
||||
if (this.state !== 'recording' && this.state !== 'paused') {
|
||||
throw new Error(`Cannot stop recorder while it is ${this.state}`);
|
||||
}
|
||||
|
||||
const outputPath = this.currentPath;
|
||||
if (outputPath === null) throw new Error('Recorder has no output path');
|
||||
|
||||
const pendingStop = (async (): Promise<RecordedAudio> => {
|
||||
this.state = 'stopping';
|
||||
|
||||
try {
|
||||
if (Platform.OS === 'android') {
|
||||
const snapshot = parseNativeSnapshot(await androidRecordingModule().stop());
|
||||
if (
|
||||
(snapshot.state !== 'stopped' && snapshot.state !== 'recoverable') ||
|
||||
snapshot.recording === null ||
|
||||
snapshot.recording.size <= 44
|
||||
) {
|
||||
throw new Error('Android recorder produced no recoverable audio');
|
||||
}
|
||||
this.stopAndroidPolling();
|
||||
this.currentPath = snapshot.recording.path;
|
||||
this.progress = {
|
||||
durationMs: snapshot.recording.durationMs,
|
||||
meteringDb: null,
|
||||
};
|
||||
this.state = 'stopped';
|
||||
this.stateHandler?.(snapshot);
|
||||
return snapshot.recording;
|
||||
}
|
||||
const stoppedUri = await this.getSound().stopRecorder();
|
||||
const stoppedPath = pathFromFileUri(stoppedUri);
|
||||
|
||||
if (stoppedPath !== outputPath) {
|
||||
throw new Error('Recorder did not return the expected output file');
|
||||
}
|
||||
|
||||
const stat = await FileSystem.stat(outputPath);
|
||||
if (stat.type !== 'file' || stat.size <= 0) {
|
||||
throw new Error('Recorder produced an empty audio file');
|
||||
}
|
||||
|
||||
this.state = 'stopped';
|
||||
return {
|
||||
uri: stoppedUri,
|
||||
path: outputPath,
|
||||
fileName: outputPath.slice(outputPath.lastIndexOf('/') + 1),
|
||||
mimeType: RECORDING_MIME_TYPE,
|
||||
size: stat.size,
|
||||
durationMs: this.progress.durationMs,
|
||||
};
|
||||
} catch (error) {
|
||||
this.currentPath = null;
|
||||
this.state = 'idle';
|
||||
await deleteOwnedTemporaryFile(outputPath);
|
||||
throw error;
|
||||
} finally {
|
||||
this.progressHandler = null;
|
||||
this.stateHandler = null;
|
||||
}
|
||||
})();
|
||||
|
||||
this.pendingStop = pendingStop;
|
||||
try {
|
||||
return await pendingStop;
|
||||
} finally {
|
||||
if (this.pendingStop === pendingStop) this.pendingStop = null;
|
||||
}
|
||||
}
|
||||
|
||||
async cleanup(recording?: Pick<RecordedAudio, 'path'>): Promise<void> {
|
||||
const path = recording?.path ?? this.currentPath;
|
||||
if (Platform.OS === 'android') {
|
||||
if (path !== null) await androidRecordingModule().dispose(path);
|
||||
this.stopAndroidPolling();
|
||||
this.currentPath = null;
|
||||
this.state = 'idle';
|
||||
this.progress = { durationMs: 0, meteringDb: null };
|
||||
this.progressHandler = null;
|
||||
this.stateHandler = null;
|
||||
return;
|
||||
}
|
||||
await deleteOwnedTemporaryFile(path);
|
||||
|
||||
if (path === this.currentPath) {
|
||||
this.currentPath = null;
|
||||
this.state = 'idle';
|
||||
this.progress = { durationMs: 0, meteringDb: null };
|
||||
}
|
||||
/**
|
||||
* Returns a session for `owner`, or throws {@link RecorderBusyError} without
|
||||
* touching the capture when another owner holds it.
|
||||
*/
|
||||
async acquire(owner: RecorderOwner): Promise<RecorderSession> {
|
||||
await this.assertAvailable(owner);
|
||||
return this.createSession(owner);
|
||||
}
|
||||
|
||||
/**
|
||||
* Privileged discard of whatever capture exists, regardless of owner. Only
|
||||
* the account-purge boundary may call this; screens use their session.
|
||||
*/
|
||||
async cancel(): Promise<void> {
|
||||
if (Platform.OS === 'android') {
|
||||
this.stopAndroidPolling();
|
||||
await androidRecordingModule().cancel();
|
||||
this.progressHandler = null;
|
||||
this.stateHandler = null;
|
||||
this.currentPath = null;
|
||||
this.state = 'idle';
|
||||
this.progress = { durationMs: 0, meteringDb: null };
|
||||
return;
|
||||
}
|
||||
if (this.state === 'starting' && this.pendingStart !== null) {
|
||||
try {
|
||||
await this.pendingStart;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state === 'stopping' && this.pendingStop !== null) {
|
||||
try {
|
||||
await this.pendingStop;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.pendingTransition !== null) {
|
||||
try {
|
||||
await this.pendingTransition;
|
||||
} catch {
|
||||
// Continue cleanup from the stable pre-transition recording state.
|
||||
}
|
||||
}
|
||||
|
||||
const outputPath = this.currentPath;
|
||||
let stopError: unknown = null;
|
||||
|
||||
try {
|
||||
if (this.state === 'recording' || this.state === 'paused') {
|
||||
await this.getSound().stopRecorder();
|
||||
}
|
||||
} catch (error) {
|
||||
stopError = error;
|
||||
await this.backend.cancel();
|
||||
} finally {
|
||||
this.progressHandler = null;
|
||||
this.currentPath = null;
|
||||
this.state = 'idle';
|
||||
this.progress = { durationMs: 0, meteringDb: null };
|
||||
await deleteOwnedTemporaryFile(outputPath);
|
||||
this.lease = null;
|
||||
}
|
||||
|
||||
if (stopError !== null) throw stopError;
|
||||
}
|
||||
|
||||
async restore(
|
||||
onProgress?: (progress: RecordingProgress) => void,
|
||||
onStateChange?: (snapshot: RecordingRuntimeSnapshot) => void,
|
||||
): Promise<RecordingRuntimeSnapshot> {
|
||||
if (Platform.OS !== 'android') {
|
||||
return {
|
||||
state: 'idle',
|
||||
recording: null,
|
||||
meetingId: null,
|
||||
interruptionReason: null,
|
||||
startedAtMs: 0,
|
||||
};
|
||||
}
|
||||
const snapshot = parseNativeSnapshot(await androidRecordingModule().getStatus());
|
||||
this.stopAndroidPolling();
|
||||
this.progressHandler = onProgress ?? null;
|
||||
this.stateHandler = onStateChange ?? null;
|
||||
this.currentPath = snapshot.recording?.path ?? null;
|
||||
this.progress = {
|
||||
durationMs: snapshot.recording?.durationMs ?? 0,
|
||||
meteringDb: null,
|
||||
private createSession(owner: RecorderOwner): RecorderSession {
|
||||
return {
|
||||
owner,
|
||||
start: (onProgress: RecordingProgressHandler, options?: StartRecordingOptions) =>
|
||||
this.runAsOwner(owner, true, () => this.backend.start(onProgress, options)),
|
||||
pause: () => this.runAsOwner(owner, false, () => this.backend.pause()),
|
||||
resume: () => this.runAsOwner(owner, false, () => this.backend.resume()),
|
||||
stop: () => this.runAsOwner(owner, false, () => this.backend.stop()),
|
||||
cleanup: (recording?: Pick<RecordedAudio, 'path'>) => this.cleanupAsOwner(owner, recording),
|
||||
cancel: () => this.cancelAsOwner(owner),
|
||||
restore: (onProgress?: RecordingProgressHandler, onStateChange?: RecordingStateHandler) =>
|
||||
this.runAsOwner(owner, true, () => this.backend.restore(onProgress, onStateChange)),
|
||||
};
|
||||
if (snapshot.state === 'recording' || snapshot.state === 'paused') {
|
||||
this.state = snapshot.state;
|
||||
this.startAndroidPolling();
|
||||
} else if (snapshot.state === 'stopped' || snapshot.state === 'recoverable') {
|
||||
this.state = 'stopped';
|
||||
} else {
|
||||
this.state = 'idle';
|
||||
}
|
||||
|
||||
private async holder(): Promise<RecorderOwner | null> {
|
||||
if (!(await this.backend.isBusy())) return null;
|
||||
return this.lease ?? UNLEASED_CAPTURE_OWNER;
|
||||
}
|
||||
|
||||
private async assertAvailable(owner: RecorderOwner): Promise<void> {
|
||||
const holder = await this.holder();
|
||||
if (holder !== null && holder !== owner) throw new RecorderBusyError(holder);
|
||||
// Another owner may have started a capture while the status was read.
|
||||
if (this.lease !== null && this.lease !== owner && this.backend.hasInProcessCapture()) {
|
||||
throw new RecorderBusyError(this.lease);
|
||||
}
|
||||
this.progressHandler?.(this.progress);
|
||||
this.stateHandler?.(snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private startAndroidPolling(): void {
|
||||
const generation = ++this.androidPollGeneration;
|
||||
const poll = async (): Promise<void> => {
|
||||
if (generation !== this.androidPollGeneration) return;
|
||||
try {
|
||||
const native = await androidRecordingModule().getStatus();
|
||||
if (generation !== this.androidPollGeneration) return;
|
||||
const snapshot = parseNativeSnapshot(native);
|
||||
const nextProgress = {
|
||||
durationMs: snapshot.recording?.durationMs ?? 0,
|
||||
meteringDb:
|
||||
typeof native.meteringDb === 'number' && Number.isFinite(native.meteringDb)
|
||||
? native.meteringDb
|
||||
: null,
|
||||
};
|
||||
this.progress = nextProgress;
|
||||
this.progressHandler?.(nextProgress);
|
||||
this.stateHandler?.(snapshot);
|
||||
if (snapshot.state === 'recording' || snapshot.state === 'paused') {
|
||||
this.state = snapshot.state;
|
||||
setTimeout(() => { void poll(); }, 250);
|
||||
} else {
|
||||
this.currentPath = snapshot.recording?.path ?? null;
|
||||
this.state = snapshot.state === 'idle' ? 'idle' : 'stopped';
|
||||
}
|
||||
} catch {
|
||||
// A transient React bridge failure must not stop the native foreground capture.
|
||||
if (generation === this.androidPollGeneration) {
|
||||
setTimeout(() => { void poll(); }, 500);
|
||||
}
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
private async runAsOwner<T>(
|
||||
owner: RecorderOwner,
|
||||
claim: boolean,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
await this.assertAvailable(owner);
|
||||
if (claim) this.lease = owner;
|
||||
return operation();
|
||||
}
|
||||
|
||||
private stopAndroidPolling(): void {
|
||||
this.androidPollGeneration += 1;
|
||||
private async cleanupAsOwner(
|
||||
owner: RecorderOwner,
|
||||
recording?: Pick<RecordedAudio, 'path'>,
|
||||
): Promise<void> {
|
||||
// An explicit recording is path-scoped and cannot touch another capture;
|
||||
// an implicit cleanup targets the current capture and needs ownership.
|
||||
if (recording === undefined) await this.assertAvailable(owner);
|
||||
await this.backend.cleanup(recording);
|
||||
this.releaseIfIdle(owner);
|
||||
}
|
||||
|
||||
private async cancelAsOwner(owner: RecorderOwner): Promise<void> {
|
||||
if ((await this.holder()) !== owner) return;
|
||||
if (this.lease !== null && this.lease !== owner) return;
|
||||
try {
|
||||
await this.backend.cancel();
|
||||
} finally {
|
||||
this.releaseIfIdle(owner);
|
||||
}
|
||||
}
|
||||
|
||||
private releaseIfIdle(owner: RecorderOwner): void {
|
||||
if (this.lease === owner && !this.backend.hasInProcessCapture()) this.lease = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
407
apps/mobile-rn/src/lib/recorder/android-foreground-backend.ts
Normal file
407
apps/mobile-rn/src/lib/recorder/android-foreground-backend.ts
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
import { NativeModules, PermissionsAndroid } from 'react-native';
|
||||
import type {
|
||||
RecordedAudio,
|
||||
RecorderBackend,
|
||||
RecordingProgress,
|
||||
RecordingProgressHandler,
|
||||
RecordingRuntimeSnapshot,
|
||||
RecordingRuntimeState,
|
||||
RecordingStateHandler,
|
||||
StartRecordingOptions,
|
||||
} from './recorder-types';
|
||||
|
||||
const ANDROID_RECORDING_MIME_TYPE = 'audio/wav';
|
||||
/** A WAV with nothing but its RIFF header holds no audio. */
|
||||
const WAV_HEADER_BYTES = 44;
|
||||
|
||||
type AndroidRecorderState =
|
||||
| 'idle'
|
||||
| 'starting'
|
||||
| 'recording'
|
||||
| 'pausing'
|
||||
| 'paused'
|
||||
| 'resuming'
|
||||
| 'stopping'
|
||||
| 'stopped';
|
||||
|
||||
interface NativeRecordingSnapshot {
|
||||
state: RecordingRuntimeState;
|
||||
path: string | null;
|
||||
uri: string | null;
|
||||
fileName: string | null;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
durationMs: number;
|
||||
meteringDb: number | null;
|
||||
meetingId: string | null;
|
||||
interruptionReason: string | null;
|
||||
startedAtMs: number;
|
||||
recoverable: boolean;
|
||||
}
|
||||
|
||||
interface D3RORecordingNativeModule {
|
||||
getStatus: () => Promise<NativeRecordingSnapshot>;
|
||||
start: (options: { meetingId?: string }) => Promise<NativeRecordingSnapshot>;
|
||||
pause: () => Promise<NativeRecordingSnapshot>;
|
||||
resume: () => Promise<NativeRecordingSnapshot>;
|
||||
stop: () => Promise<NativeRecordingSnapshot>;
|
||||
cancel: () => Promise<NativeRecordingSnapshot>;
|
||||
dispose: (path: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function androidRecordingModule(): D3RORecordingNativeModule {
|
||||
const candidate = NativeModules.D3RORecording as
|
||||
| Partial<D3RORecordingNativeModule>
|
||||
| undefined;
|
||||
if (
|
||||
candidate === undefined ||
|
||||
typeof candidate.getStatus !== 'function' ||
|
||||
typeof candidate.start !== 'function' ||
|
||||
typeof candidate.pause !== 'function' ||
|
||||
typeof candidate.resume !== 'function' ||
|
||||
typeof candidate.stop !== 'function' ||
|
||||
typeof candidate.cancel !== 'function' ||
|
||||
typeof candidate.dispose !== 'function'
|
||||
) {
|
||||
throw new Error('Android foreground recording module is unavailable');
|
||||
}
|
||||
return candidate as D3RORecordingNativeModule;
|
||||
}
|
||||
|
||||
function assertFiniteNonNegative(value: unknown, field: string): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`Android recorder returned invalid ${field}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseNativeSnapshot(value: NativeRecordingSnapshot): RecordingRuntimeSnapshot {
|
||||
const states: readonly RecordingRuntimeState[] = [
|
||||
'idle',
|
||||
'starting',
|
||||
'recording',
|
||||
'paused',
|
||||
'stopped',
|
||||
'recoverable',
|
||||
];
|
||||
if (!states.includes(value.state)) {
|
||||
throw new Error('Android recorder returned an invalid state');
|
||||
}
|
||||
const durationMs = assertFiniteNonNegative(value.durationMs, 'duration');
|
||||
const size = assertFiniteNonNegative(value.size, 'file size');
|
||||
const startedAtMs = assertFiniteNonNegative(value.startedAtMs, 'start time');
|
||||
const hasFile = value.path !== null || value.uri !== null || value.fileName !== null;
|
||||
if (
|
||||
hasFile &&
|
||||
(typeof value.path !== 'string' ||
|
||||
value.path.length === 0 ||
|
||||
value.uri !== `file://${value.path}` ||
|
||||
typeof value.fileName !== 'string' ||
|
||||
!value.fileName.endsWith('.wav') ||
|
||||
value.mimeType !== ANDROID_RECORDING_MIME_TYPE)
|
||||
) {
|
||||
throw new Error('Android recorder returned an invalid recording file');
|
||||
}
|
||||
if (value.state !== 'idle' && !hasFile) {
|
||||
throw new Error('Android recorder state has no recording file');
|
||||
}
|
||||
const recording = hasFile
|
||||
? {
|
||||
uri: value.uri as string,
|
||||
path: value.path as string,
|
||||
fileName: value.fileName as string,
|
||||
mimeType: ANDROID_RECORDING_MIME_TYPE,
|
||||
size,
|
||||
durationMs,
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
state: value.state,
|
||||
recording,
|
||||
meetingId: typeof value.meetingId === 'string' ? value.meetingId : null,
|
||||
interruptionReason:
|
||||
typeof value.interruptionReason === 'string'
|
||||
? value.interruptionReason
|
||||
: null,
|
||||
startedAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Android capture through the D3RORecording foreground service. The native
|
||||
* side owns the WAV file (under filesDir) and its journal, so this backend
|
||||
* never deletes files itself: it only asks the module to cancel or dispose.
|
||||
*/
|
||||
export class AndroidForegroundRecorderBackend implements RecorderBackend {
|
||||
private state: AndroidRecorderState = 'idle';
|
||||
private currentPath: string | null = null;
|
||||
private progress: RecordingProgress = { durationMs: 0, meteringDb: null };
|
||||
private progressHandler: RecordingProgressHandler | null = null;
|
||||
private stateHandler: RecordingStateHandler | null = null;
|
||||
private pollGeneration = 0;
|
||||
|
||||
async requestPermission(): Promise<boolean> {
|
||||
const permission = PermissionsAndroid.PERMISSIONS.RECORD_AUDIO;
|
||||
if (await PermissionsAndroid.check(permission)) return true;
|
||||
|
||||
const result = await PermissionsAndroid.request(permission, {
|
||||
title: 'Microphone access',
|
||||
message:
|
||||
'D3RO Voice needs microphone access to record and transcribe audio.',
|
||||
buttonPositive: 'Allow',
|
||||
buttonNegative: 'Cancel',
|
||||
});
|
||||
|
||||
return result === PermissionsAndroid.RESULTS.GRANTED;
|
||||
}
|
||||
|
||||
async start(
|
||||
onProgress: RecordingProgressHandler,
|
||||
options: StartRecordingOptions = {},
|
||||
): Promise<void> {
|
||||
if (this.state !== 'idle') {
|
||||
throw new Error(`Cannot start recorder while it is ${this.state}`);
|
||||
}
|
||||
|
||||
this.state = 'starting';
|
||||
this.progressHandler = onProgress;
|
||||
this.stateHandler = options.onStateChange ?? null;
|
||||
try {
|
||||
const snapshot = parseNativeSnapshot(
|
||||
await androidRecordingModule().start(
|
||||
options.meetingId === undefined
|
||||
? {}
|
||||
: { meetingId: options.meetingId },
|
||||
),
|
||||
);
|
||||
if (snapshot.state !== 'recording' || snapshot.recording === null) {
|
||||
throw new Error('Android foreground recorder did not start');
|
||||
}
|
||||
this.currentPath = snapshot.recording.path;
|
||||
this.progress = {
|
||||
durationMs: snapshot.recording.durationMs,
|
||||
meteringDb: null,
|
||||
};
|
||||
this.state = 'recording';
|
||||
this.stateHandler?.(snapshot);
|
||||
this.startPolling();
|
||||
} catch (error) {
|
||||
this.progressHandler = null;
|
||||
this.stateHandler = null;
|
||||
this.currentPath = null;
|
||||
this.state = 'idle';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
if (this.state !== 'recording') {
|
||||
throw new Error(`Cannot pause recorder while it is ${this.state}`);
|
||||
}
|
||||
|
||||
this.state = 'pausing';
|
||||
try {
|
||||
const snapshot = parseNativeSnapshot(await androidRecordingModule().pause());
|
||||
if (snapshot.state !== 'paused') throw new Error('Android recorder did not pause');
|
||||
this.state = 'paused';
|
||||
this.stateHandler?.(snapshot);
|
||||
} catch (error) {
|
||||
this.state = 'recording';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async resume(): Promise<void> {
|
||||
if (this.state !== 'paused') {
|
||||
throw new Error(`Cannot resume recorder while it is ${this.state}`);
|
||||
}
|
||||
|
||||
this.state = 'resuming';
|
||||
try {
|
||||
const snapshot = parseNativeSnapshot(await androidRecordingModule().resume());
|
||||
if (snapshot.state !== 'recording') throw new Error('Android recorder did not resume');
|
||||
this.state = 'recording';
|
||||
this.stateHandler?.(snapshot);
|
||||
} catch (error) {
|
||||
this.state = 'paused';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<RecordedAudio> {
|
||||
if (this.state !== 'recording' && this.state !== 'paused') {
|
||||
throw new Error(`Cannot stop recorder while it is ${this.state}`);
|
||||
}
|
||||
if (this.currentPath === null) throw new Error('Recorder has no output path');
|
||||
|
||||
const previousState = this.state;
|
||||
this.state = 'stopping';
|
||||
let snapshot: RecordingRuntimeSnapshot;
|
||||
try {
|
||||
snapshot = parseNativeSnapshot(await androidRecordingModule().stop());
|
||||
} catch (error) {
|
||||
await this.resyncAfterFailedStop(previousState, null);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const recording = snapshot.recording;
|
||||
if (
|
||||
(snapshot.state !== 'stopped' && snapshot.state !== 'recoverable') ||
|
||||
recording === null ||
|
||||
recording.size <= WAV_HEADER_BYTES
|
||||
) {
|
||||
const error = new Error('Android recorder produced no recoverable audio');
|
||||
await this.resyncAfterFailedStop(previousState, snapshot);
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.stopPolling();
|
||||
this.currentPath = recording.path;
|
||||
this.progress = { durationMs: recording.durationMs, meteringDb: null };
|
||||
this.state = 'stopped';
|
||||
const stateHandler = this.stateHandler;
|
||||
this.progressHandler = null;
|
||||
this.stateHandler = null;
|
||||
stateHandler?.(snapshot);
|
||||
return recording;
|
||||
}
|
||||
|
||||
async cleanup(recording?: Pick<RecordedAudio, 'path'>): Promise<void> {
|
||||
const path = recording?.path ?? this.currentPath;
|
||||
if (path !== null) await androidRecordingModule().dispose(path);
|
||||
// Disposing an older file must not reset a capture that is running now.
|
||||
if (recording !== undefined && this.currentPath !== null && path !== this.currentPath) {
|
||||
return;
|
||||
}
|
||||
this.stopPolling();
|
||||
this.resetToIdle();
|
||||
}
|
||||
|
||||
async cancel(): Promise<void> {
|
||||
this.stopPolling();
|
||||
await androidRecordingModule().cancel();
|
||||
this.resetToIdle();
|
||||
}
|
||||
|
||||
async restore(
|
||||
onProgress?: RecordingProgressHandler,
|
||||
onStateChange?: RecordingStateHandler,
|
||||
): Promise<RecordingRuntimeSnapshot> {
|
||||
const snapshot = parseNativeSnapshot(await androidRecordingModule().getStatus());
|
||||
this.stopPolling();
|
||||
this.progressHandler = onProgress ?? null;
|
||||
this.stateHandler = onStateChange ?? null;
|
||||
this.adoptSnapshot(snapshot);
|
||||
this.progressHandler?.(this.progress);
|
||||
this.stateHandler?.(snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async isBusy(): Promise<boolean> {
|
||||
if (this.state !== 'idle') return true;
|
||||
// The foreground service outlives the JS process, so idle JS state alone
|
||||
// does not prove the microphone and its journal are free.
|
||||
const snapshot = parseNativeSnapshot(await androidRecordingModule().getStatus());
|
||||
return snapshot.state !== 'idle';
|
||||
}
|
||||
|
||||
hasInProcessCapture(): boolean {
|
||||
return this.state !== 'idle';
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed stop must leave the recording where the native side says it is:
|
||||
* still capturing, or stopped/recoverable with its WAV intact. The owner is
|
||||
* told through its state handler so it can offer process/discard actions.
|
||||
*/
|
||||
private async resyncAfterFailedStop(
|
||||
previousState: 'recording' | 'paused',
|
||||
known: RecordingRuntimeSnapshot | null,
|
||||
): Promise<void> {
|
||||
this.stopPolling();
|
||||
let snapshot = known;
|
||||
if (snapshot === null) {
|
||||
try {
|
||||
snapshot = parseNativeSnapshot(await androidRecordingModule().getStatus());
|
||||
} catch {
|
||||
snapshot = null;
|
||||
}
|
||||
}
|
||||
if (snapshot === null) {
|
||||
// Native state is unreadable right now; keep the last known capture and
|
||||
// let polling report whatever the service settles on.
|
||||
this.state = previousState;
|
||||
this.startPolling();
|
||||
return;
|
||||
}
|
||||
this.adoptSnapshot(snapshot);
|
||||
this.stateHandler?.(snapshot);
|
||||
if (this.state !== 'recording' && this.state !== 'paused') {
|
||||
this.progressHandler = null;
|
||||
this.stateHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
private adoptSnapshot(snapshot: RecordingRuntimeSnapshot): void {
|
||||
this.currentPath = snapshot.recording?.path ?? null;
|
||||
this.progress = {
|
||||
durationMs: snapshot.recording?.durationMs ?? 0,
|
||||
meteringDb: null,
|
||||
};
|
||||
if (snapshot.state === 'recording' || snapshot.state === 'paused') {
|
||||
this.state = snapshot.state;
|
||||
this.startPolling();
|
||||
} else if (snapshot.state === 'stopped' || snapshot.state === 'recoverable') {
|
||||
this.state = 'stopped';
|
||||
} else {
|
||||
this.state = 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
private resetToIdle(): void {
|
||||
this.progressHandler = null;
|
||||
this.stateHandler = null;
|
||||
this.currentPath = null;
|
||||
this.state = 'idle';
|
||||
this.progress = { durationMs: 0, meteringDb: null };
|
||||
}
|
||||
|
||||
private startPolling(): void {
|
||||
const generation = ++this.pollGeneration;
|
||||
const poll = async (): Promise<void> => {
|
||||
if (generation !== this.pollGeneration) return;
|
||||
try {
|
||||
const native = await androidRecordingModule().getStatus();
|
||||
if (generation !== this.pollGeneration) return;
|
||||
const snapshot = parseNativeSnapshot(native);
|
||||
const nextProgress = {
|
||||
durationMs: snapshot.recording?.durationMs ?? 0,
|
||||
meteringDb:
|
||||
typeof native.meteringDb === 'number' && Number.isFinite(native.meteringDb)
|
||||
? native.meteringDb
|
||||
: null,
|
||||
};
|
||||
this.progress = nextProgress;
|
||||
this.progressHandler?.(nextProgress);
|
||||
this.stateHandler?.(snapshot);
|
||||
if (snapshot.state === 'recording' || snapshot.state === 'paused') {
|
||||
this.state = snapshot.state;
|
||||
setTimeout(() => { void poll(); }, 250);
|
||||
} else {
|
||||
this.currentPath = snapshot.recording?.path ?? null;
|
||||
this.state = snapshot.state === 'idle' ? 'idle' : 'stopped';
|
||||
}
|
||||
} catch {
|
||||
// A transient React bridge failure must not stop the native foreground capture.
|
||||
if (generation === this.pollGeneration) {
|
||||
setTimeout(() => { void poll(); }, 500);
|
||||
}
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
this.pollGeneration += 1;
|
||||
}
|
||||
}
|
||||
373
apps/mobile-rn/src/lib/recorder/nitro-backend.ts
Normal file
373
apps/mobile-rn/src/lib/recorder/nitro-backend.ts
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
import { Dirs, FileSystem } from 'react-native-file-access';
|
||||
import {
|
||||
AudioEncoderAndroidType,
|
||||
AudioSourceAndroidType,
|
||||
AVEncoderAudioQualityIOSType,
|
||||
OutputFormatAndroidType,
|
||||
createSound,
|
||||
type AudioSet,
|
||||
type RecordBackType,
|
||||
} from 'react-native-nitro-sound';
|
||||
import { createUuidV4 } from '../random-id';
|
||||
import type {
|
||||
RecordedAudio,
|
||||
RecorderBackend,
|
||||
RecordingProgress,
|
||||
RecordingProgressHandler,
|
||||
RecordingRuntimeSnapshot,
|
||||
} from './recorder-types';
|
||||
|
||||
const RECORDING_PREFIX = 'd3ro-recording-';
|
||||
const RECORDING_EXTENSION = '.m4a';
|
||||
const RECORDING_MIME_TYPE = 'audio/mp4';
|
||||
|
||||
const AUDIO_SETTINGS: AudioSet = {
|
||||
AudioSourceAndroid: AudioSourceAndroidType.MIC,
|
||||
OutputFormatAndroid: OutputFormatAndroidType.MPEG_4,
|
||||
AudioEncoderAndroid: AudioEncoderAndroidType.AAC,
|
||||
AudioQuality: 'medium',
|
||||
AudioChannels: 1,
|
||||
AudioSamplingRate: 44100,
|
||||
AudioEncodingBitRate: 128000,
|
||||
AVFormatIDKeyIOS: 'aac',
|
||||
AVEncoderAudioQualityKeyIOS: AVEncoderAudioQualityIOSType.high,
|
||||
AVNumberOfChannelsKeyIOS: 1,
|
||||
AVSampleRateKeyIOS: 44100,
|
||||
};
|
||||
|
||||
type NitroRecorderState =
|
||||
| 'idle'
|
||||
| 'starting'
|
||||
| 'recording'
|
||||
| 'pausing'
|
||||
| 'paused'
|
||||
| 'resuming'
|
||||
| 'stopping'
|
||||
| 'stopped';
|
||||
|
||||
function createTemporaryPath(): string {
|
||||
return `${Dirs.CacheDir}/${RECORDING_PREFIX}${createUuidV4()}${RECORDING_EXTENSION}`;
|
||||
}
|
||||
|
||||
function pathFromFileUri(uri: string): string | null {
|
||||
if (!uri.startsWith('file://')) return null;
|
||||
|
||||
try {
|
||||
return decodeURIComponent(uri.slice('file://'.length));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isOwnedTemporaryPath(path: string): boolean {
|
||||
const cachePrefix = `${Dirs.CacheDir}/`;
|
||||
if (!path.startsWith(cachePrefix)) return false;
|
||||
|
||||
const fileName = path.slice(cachePrefix.length);
|
||||
return (
|
||||
!fileName.includes('/') &&
|
||||
!fileName.includes('\\') &&
|
||||
fileName.startsWith(RECORDING_PREFIX) &&
|
||||
fileName.endsWith(RECORDING_EXTENSION)
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteOwnedTemporaryFile(path: string | null): Promise<void> {
|
||||
if (path === null) return;
|
||||
if (!isOwnedTemporaryPath(path)) {
|
||||
throw new Error('Refusing to delete a recording outside the app cache');
|
||||
}
|
||||
|
||||
if (await FileSystem.exists(path)) {
|
||||
await FileSystem.unlink(path);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteStaleTemporaryFiles(): Promise<void> {
|
||||
const entries = await FileSystem.ls(Dirs.CacheDir);
|
||||
const stalePaths = entries
|
||||
.filter(
|
||||
entry =>
|
||||
entry.startsWith(RECORDING_PREFIX) &&
|
||||
entry.endsWith(RECORDING_EXTENSION),
|
||||
)
|
||||
.map(entry => `${Dirs.CacheDir}/${entry}`);
|
||||
|
||||
await Promise.all(stalePaths.map(deleteOwnedTemporaryFile));
|
||||
}
|
||||
|
||||
/**
|
||||
* In-process capture through Nitro Sound (iOS). This backend creates its
|
||||
* .m4a files in the app cache and is the only code allowed to delete them.
|
||||
*/
|
||||
export class NitroRecorderBackend implements RecorderBackend {
|
||||
private state: NitroRecorderState = 'idle';
|
||||
private currentPath: string | null = null;
|
||||
private progress: RecordingProgress = { durationMs: 0, meteringDb: null };
|
||||
private pendingStart: Promise<void> | null = null;
|
||||
private pendingTransition: Promise<void> | null = null;
|
||||
private pendingStop: Promise<RecordedAudio> | null = null;
|
||||
private sound: ReturnType<typeof createSound> | null = null;
|
||||
private progressHandler: RecordingProgressHandler | null = null;
|
||||
|
||||
private getSound(): ReturnType<typeof createSound> {
|
||||
if (this.sound !== null) return this.sound;
|
||||
|
||||
const sound = createSound();
|
||||
sound.setSubscriptionDuration(0.1);
|
||||
sound.addRecordBackListener((event: RecordBackType) => {
|
||||
if (this.progressHandler === null) return;
|
||||
|
||||
const durationMs = Number.isFinite(event.currentPosition)
|
||||
? Math.max(0, Math.round(event.currentPosition))
|
||||
: this.progress.durationMs;
|
||||
const meteringDb =
|
||||
typeof event.currentMetering === 'number' &&
|
||||
Number.isFinite(event.currentMetering)
|
||||
? event.currentMetering
|
||||
: null;
|
||||
|
||||
this.progress = { durationMs, meteringDb };
|
||||
this.progressHandler(this.progress);
|
||||
});
|
||||
this.sound = sound;
|
||||
return sound;
|
||||
}
|
||||
|
||||
async requestPermission(): Promise<boolean> {
|
||||
// Nitro Sound requests iOS microphone access through AVAudioSession.
|
||||
return true;
|
||||
}
|
||||
|
||||
async start(onProgress: RecordingProgressHandler): Promise<void> {
|
||||
if (this.state !== 'idle') {
|
||||
throw new Error(`Cannot start recorder while it is ${this.state}`);
|
||||
}
|
||||
|
||||
this.state = 'starting';
|
||||
try {
|
||||
await deleteStaleTemporaryFiles();
|
||||
} catch (error) {
|
||||
this.state = 'idle';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const outputPath = createTemporaryPath();
|
||||
this.currentPath = outputPath;
|
||||
this.progress = { durationMs: 0, meteringDb: null };
|
||||
this.progressHandler = onProgress;
|
||||
const sound = this.getSound();
|
||||
|
||||
const pendingStart = (async () => {
|
||||
try {
|
||||
const startedUri = await sound.startRecorder(
|
||||
outputPath,
|
||||
AUDIO_SETTINGS,
|
||||
true,
|
||||
);
|
||||
const startedPath = pathFromFileUri(startedUri);
|
||||
|
||||
if (startedPath !== outputPath) {
|
||||
throw new Error('Recorder returned an unexpected output URI');
|
||||
}
|
||||
|
||||
this.state = 'recording';
|
||||
} catch (error) {
|
||||
try {
|
||||
await sound.stopRecorder();
|
||||
} catch {
|
||||
// stopRecorder also releases the native recorder when stopping fails.
|
||||
}
|
||||
this.progressHandler = null;
|
||||
if (this.currentPath === outputPath) this.currentPath = null;
|
||||
this.state = 'idle';
|
||||
await deleteOwnedTemporaryFile(outputPath);
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
this.pendingStart = pendingStart;
|
||||
try {
|
||||
await pendingStart;
|
||||
} finally {
|
||||
if (this.pendingStart === pendingStart) this.pendingStart = null;
|
||||
}
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
if (this.state !== 'recording') {
|
||||
throw new Error(`Cannot pause recorder while it is ${this.state}`);
|
||||
}
|
||||
|
||||
this.state = 'pausing';
|
||||
await this.runTransition(async () => {
|
||||
try {
|
||||
await this.getSound().pauseRecorder();
|
||||
this.state = 'paused';
|
||||
} catch (error) {
|
||||
this.state = 'recording';
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async resume(): Promise<void> {
|
||||
if (this.state !== 'paused') {
|
||||
throw new Error(`Cannot resume recorder while it is ${this.state}`);
|
||||
}
|
||||
|
||||
this.state = 'resuming';
|
||||
await this.runTransition(async () => {
|
||||
try {
|
||||
await this.getSound().resumeRecorder();
|
||||
this.state = 'recording';
|
||||
} catch (error) {
|
||||
this.state = 'paused';
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<RecordedAudio> {
|
||||
if (this.state !== 'recording' && this.state !== 'paused') {
|
||||
throw new Error(`Cannot stop recorder while it is ${this.state}`);
|
||||
}
|
||||
|
||||
const outputPath = this.currentPath;
|
||||
if (outputPath === null) throw new Error('Recorder has no output path');
|
||||
|
||||
const pendingStop = (async (): Promise<RecordedAudio> => {
|
||||
this.state = 'stopping';
|
||||
|
||||
try {
|
||||
const stoppedUri = await this.getSound().stopRecorder();
|
||||
const stoppedPath = pathFromFileUri(stoppedUri);
|
||||
|
||||
if (stoppedPath !== outputPath) {
|
||||
throw new Error('Recorder did not return the expected output file');
|
||||
}
|
||||
|
||||
const stat = await FileSystem.stat(outputPath);
|
||||
if (stat.type !== 'file' || stat.size <= 0) {
|
||||
throw new Error('Recorder produced an empty audio file');
|
||||
}
|
||||
|
||||
this.state = 'stopped';
|
||||
return {
|
||||
uri: stoppedUri,
|
||||
path: outputPath,
|
||||
fileName: outputPath.slice(outputPath.lastIndexOf('/') + 1),
|
||||
mimeType: RECORDING_MIME_TYPE,
|
||||
size: stat.size,
|
||||
durationMs: this.progress.durationMs,
|
||||
};
|
||||
} catch (error) {
|
||||
this.currentPath = null;
|
||||
this.state = 'idle';
|
||||
try {
|
||||
await deleteOwnedTemporaryFile(outputPath);
|
||||
} catch {
|
||||
// The stop failure is what the caller must see; a stale cache file
|
||||
// is swept by deleteStaleTemporaryFiles() on the next start.
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
this.progressHandler = null;
|
||||
}
|
||||
})();
|
||||
|
||||
this.pendingStop = pendingStop;
|
||||
try {
|
||||
return await pendingStop;
|
||||
} finally {
|
||||
if (this.pendingStop === pendingStop) this.pendingStop = null;
|
||||
}
|
||||
}
|
||||
|
||||
async cleanup(recording?: Pick<RecordedAudio, 'path'>): Promise<void> {
|
||||
const path = recording?.path ?? this.currentPath;
|
||||
await deleteOwnedTemporaryFile(path);
|
||||
|
||||
if (path === this.currentPath) {
|
||||
this.currentPath = null;
|
||||
this.state = 'idle';
|
||||
this.progress = { durationMs: 0, meteringDb: null };
|
||||
}
|
||||
}
|
||||
|
||||
async cancel(): Promise<void> {
|
||||
if (this.state === 'starting' && this.pendingStart !== null) {
|
||||
try {
|
||||
await this.pendingStart;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state === 'stopping' && this.pendingStop !== null) {
|
||||
try {
|
||||
await this.pendingStop;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.pendingTransition !== null) {
|
||||
try {
|
||||
await this.pendingTransition;
|
||||
} catch {
|
||||
// Continue cleanup from the stable pre-transition recording state.
|
||||
}
|
||||
}
|
||||
|
||||
const outputPath = this.currentPath;
|
||||
let stopError: unknown = null;
|
||||
|
||||
try {
|
||||
if (this.state === 'recording' || this.state === 'paused') {
|
||||
await this.getSound().stopRecorder();
|
||||
}
|
||||
} catch (error) {
|
||||
stopError = error;
|
||||
} finally {
|
||||
this.progressHandler = null;
|
||||
this.currentPath = null;
|
||||
this.state = 'idle';
|
||||
this.progress = { durationMs: 0, meteringDb: null };
|
||||
await deleteOwnedTemporaryFile(outputPath);
|
||||
}
|
||||
|
||||
if (stopError !== null) throw stopError;
|
||||
}
|
||||
|
||||
async restore(): Promise<RecordingRuntimeSnapshot> {
|
||||
// Nitro captures die with the JS process; there is nothing to reattach.
|
||||
return {
|
||||
state: 'idle',
|
||||
recording: null,
|
||||
meetingId: null,
|
||||
interruptionReason: null,
|
||||
startedAtMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async isBusy(): Promise<boolean> {
|
||||
return this.state !== 'idle';
|
||||
}
|
||||
|
||||
hasInProcessCapture(): boolean {
|
||||
return this.state !== 'idle';
|
||||
}
|
||||
|
||||
private async runTransition(transition: () => Promise<void>): Promise<void> {
|
||||
const pendingTransition = transition();
|
||||
this.pendingTransition = pendingTransition;
|
||||
try {
|
||||
await pendingTransition;
|
||||
} finally {
|
||||
if (this.pendingTransition === pendingTransition) {
|
||||
this.pendingTransition = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
apps/mobile-rn/src/lib/recorder/recorder-errors.ts
Normal file
14
apps/mobile-rn/src/lib/recorder/recorder-errors.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { RecorderOwner } from './recorder-types';
|
||||
|
||||
/**
|
||||
* Thrown before any side effect when another owner holds the shared capture.
|
||||
* Callers must not try to clean up after it: they never owned anything.
|
||||
*/
|
||||
export class RecorderBusyError extends Error {
|
||||
readonly code = 'recorder_busy';
|
||||
|
||||
constructor(readonly holder: RecorderOwner) {
|
||||
super(`The recorder is in use by ${holder}`);
|
||||
this.name = 'RecorderBusyError';
|
||||
}
|
||||
}
|
||||
87
apps/mobile-rn/src/lib/recorder/recorder-types.ts
Normal file
87
apps/mobile-rn/src/lib/recorder/recorder-types.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
export interface RecordingProgress {
|
||||
durationMs: number;
|
||||
meteringDb: number | null;
|
||||
}
|
||||
|
||||
export interface RecordedAudio {
|
||||
uri: string;
|
||||
path: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export type RecordingRuntimeState =
|
||||
| 'idle'
|
||||
| 'starting'
|
||||
| 'recording'
|
||||
| 'paused'
|
||||
| 'stopped'
|
||||
| 'recoverable';
|
||||
|
||||
export interface RecordingRuntimeSnapshot {
|
||||
state: RecordingRuntimeState;
|
||||
recording: RecordedAudio | null;
|
||||
meetingId: string | null;
|
||||
interruptionReason: string | null;
|
||||
startedAtMs: number;
|
||||
}
|
||||
|
||||
export type RecordingProgressHandler = (progress: RecordingProgress) => void;
|
||||
export type RecordingStateHandler = (snapshot: RecordingRuntimeSnapshot) => void;
|
||||
|
||||
export interface StartRecordingOptions {
|
||||
meetingId?: string;
|
||||
onStateChange?: RecordingStateHandler;
|
||||
}
|
||||
|
||||
/** Screens that may hold the shared microphone capture. */
|
||||
export type RecorderOwner = 'record' | 'talk';
|
||||
|
||||
/**
|
||||
* Platform capture port. Each implementation owns its native API, its state
|
||||
* machine and its file-ownership/cleanup policy; nothing is shared between
|
||||
* platforms, so one platform's cleanup rule cannot leak into another's.
|
||||
*/
|
||||
export interface RecorderBackend {
|
||||
requestPermission(): Promise<boolean>;
|
||||
start(onProgress: RecordingProgressHandler, options?: StartRecordingOptions): Promise<void>;
|
||||
pause(): Promise<void>;
|
||||
resume(): Promise<void>;
|
||||
stop(): Promise<RecordedAudio>;
|
||||
/** Removes one finished recording. With no argument it targets the current capture. */
|
||||
cleanup(recording?: Pick<RecordedAudio, 'path'>): Promise<void>;
|
||||
/** Discards the current capture and its file, whoever started it. */
|
||||
cancel(): Promise<void>;
|
||||
restore(
|
||||
onProgress?: RecordingProgressHandler,
|
||||
onStateChange?: RecordingStateHandler,
|
||||
): Promise<RecordingRuntimeSnapshot>;
|
||||
/**
|
||||
* True while any capture exists, including one that outlived the JS process
|
||||
* or a stopped/recoverable file that has not been disposed yet.
|
||||
*/
|
||||
isBusy(): Promise<boolean>;
|
||||
/** Synchronous view of the capture this JS process is driving. */
|
||||
hasInProcessCapture(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-scoped handle on the shared recorder. Every mutating call verifies
|
||||
* that no other owner holds the capture; cancel() is a no-op for a capture the
|
||||
* owner does not hold, so one screen can never discard another screen's audio.
|
||||
*/
|
||||
export interface RecorderSession {
|
||||
readonly owner: RecorderOwner;
|
||||
start(onProgress: RecordingProgressHandler, options?: StartRecordingOptions): Promise<void>;
|
||||
pause(): Promise<void>;
|
||||
resume(): Promise<void>;
|
||||
stop(): Promise<RecordedAudio>;
|
||||
cleanup(recording?: Pick<RecordedAudio, 'path'>): Promise<void>;
|
||||
cancel(): Promise<void>;
|
||||
restore(
|
||||
onProgress?: RecordingProgressHandler,
|
||||
onStateChange?: RecordingStateHandler,
|
||||
): Promise<RecordingRuntimeSnapshot>;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue