fix(mobile-recorder): scope recorder control to its owner and keep Android recordings on stop failure

This commit is contained in:
Yun Chan 2026-09-28 00:54:01 +09:00
parent 72c4ddaefb
commit ace946d5c7
10 changed files with 1673 additions and 697 deletions

View file

@ -0,0 +1,20 @@
interface TalkAccountSession {
user: { id: string }
}
/**
* Identity of the signed-in account for Talk operations. It deliberately
* ignores the access token: supabase-js rotates it about hourly for the same
* user, and a rotation must not cancel or discard an in-flight reply.
*/
export function talkAccountKey(session: TalkAccountSession | null): string {
return session === null ? 'signed-out' : `user:${session.user.id}`
}
/** True when a result produced for `started` still belongs to the current account. */
export function isSameTalkAccount(
started: TalkAccountSession,
latest: TalkAccountSession | null,
): boolean {
return latest !== null && latest.user.id === started.user.id
}

View file

@ -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;
}
}

View 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;
}
}

View 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;
}
}
}
}

View 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';
}
}

View 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>;
}

View file

@ -22,7 +22,9 @@ import { useI18n } from '@d3ro/i18n';
import { useAuth } from '../lib/auth-context';
import {
audioRecorder,
RecorderBusyError,
type RecordedAudio,
type RecorderSession,
type RecordingProgress,
type RecordingRuntimeSnapshot,
} from '../lib/audio-recorder';
@ -67,6 +69,8 @@ type RecordingState =
| 'error';
function errorMessage(error: unknown, fallback: string): string {
// Talk holding the microphone is not a failure detail worth showing raw.
if (error instanceof RecorderBusyError) return fallback;
return error instanceof Error && error.message.trim() !== ''
? error.message
: fallback;
@ -142,12 +146,32 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
const queueItemIdRef = useRef<string | null>(null);
const recoveredRecordingRef = useRef<RecordedAudio | null>(null);
const activeMeetingIdRef = useRef<string | null>(route.params?.meetingId ?? null);
const recorder = audioRecorder;
// Record's owner-scoped handle on the shared recorder; see recordSession().
const recorderSessionRef = useRef<RecorderSession | null>(null);
const routeMeetingId = route.params?.meetingId ?? null;
const routeMeetingTitle = route.params?.meetingTitle ?? null;
const routeIncomingMediaId = route.params?.incomingMediaId ?? null;
const handledIncomingMediaIds = useRef(new Set<string>());
/**
* Record owns meeting captures, including one the Android foreground service
* kept alive across a JS restart. Throws RecorderBusyError, with no side
* effects, while Talk holds the microphone.
*/
async function recordSession(): Promise<RecorderSession> {
const existing = recorderSessionRef.current;
if (existing !== null) return existing;
const session = await audioRecorder.acquire('record');
recorderSessionRef.current = session;
return session;
}
function handleRecorderProgress(progress: RecordingProgress): void {
if (!mountedRef.current) return;
setDurationMs(progress.durationMs);
setMeteringDb(progress.meteringDb);
}
function applyRecorderSnapshot(snapshot: RecordingRuntimeSnapshot): void {
if (!mountedRef.current) return;
activeMeetingIdRef.current = snapshot.meetingId ?? activeMeetingIdRef.current;
@ -178,24 +202,20 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
useEffect(() => {
mountedRef.current = true;
void recorder.restore(
(progress: RecordingProgress) => {
if (!mountedRef.current) return;
setDurationMs(progress.durationMs);
setMeteringDb(progress.meteringDb);
},
applyRecorderSnapshot,
).catch(restoreError => {
if (!mountedRef.current) return;
setError(errorMessage(restoreError, t('mobile.rec.recoveryFailed')));
setState('error');
});
void recordSession()
.then(session => session.restore(handleRecorderProgress, applyRecorderSnapshot))
.catch(restoreError => {
// A Talk capture is not Record's to restore; leave Record idle.
if (!mountedRef.current || restoreError instanceof RecorderBusyError) return;
setError(errorMessage(restoreError, t('mobile.rec.recoveryFailed')));
setState('error');
});
return () => {
mountedRef.current = false;
};
// Foreground recording intentionally survives tab/screen unmount. Logout cleanup is global.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recorder]);
}, []);
useEffect(() => subscribeToDurableQueue(event => {
if (event.type === 'completed') {
@ -320,7 +340,11 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
recorderActiveRef.current = true;
let meetingBegun = false;
try {
const granted = await recorder.requestPermission();
// Re-check on every start (not the cached handle) so a busy recorder is
// reported before a meeting is marked as recording.
const recorder = await audioRecorder.acquire('record');
recorderSessionRef.current = recorder;
const granted = await audioRecorder.requestPermission();
if (!mountedRef.current) return;
if (!granted) {
recorderActiveRef.current = false;
@ -336,11 +360,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
} else {
activeMeetingIdRef.current = null;
}
await recorder.start((progress: RecordingProgress) => {
if (!mountedRef.current) return;
setDurationMs(progress.durationMs);
setMeteringDb(progress.meteringDb);
}, {
await recorder.start(handleRecorderProgress, {
meetingId: activeMeetingIdRef.current ?? undefined,
onStateChange: applyRecorderSnapshot,
});
@ -367,6 +387,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
if (!beginAction()) return;
setError(null);
try {
const recorder = await recordSession();
if (state === 'recording') {
await recorder.pause();
if (mountedRef.current) {
@ -391,7 +412,14 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
setError(null);
setState('stopping');
try {
const recording = await recorder.stop();
const recorder = await recordSession();
let recording: RecordedAudio;
try {
recording = await recorder.stop();
} catch (stopError) {
await recoverFromFailedStop(recorder, stopError);
return;
}
recorderActiveRef.current = false;
recoveredRecordingRef.current = null;
if (!mountedRef.current) {
@ -420,12 +448,36 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
}
}
/**
* A failed stop does not mean the audio is gone: the native capture may
* still be running or may have left a recoverable file. Re-read it so the
* user keeps the stop/cancel or process/discard actions.
*/
async function recoverFromFailedStop(
recorder: RecorderSession,
stopError: unknown,
): Promise<void> {
let snapshot: RecordingRuntimeSnapshot | null;
try {
snapshot = await recorder.restore(handleRecorderProgress, applyRecorderSnapshot);
} catch {
snapshot = null;
}
if (snapshot !== null && snapshot.state === 'idle') recorderActiveRef.current = false;
if (!mountedRef.current) return;
setError(errorMessage(stopError, t('mobile.rec.processFailed')));
// Otherwise applyRecorderSnapshot already moved the UI to the live capture
// or to the recoverable file.
if (snapshot === null || snapshot.state === 'idle') setState('error');
}
async function processRecoveredRecording(): Promise<void> {
const recording = recoveredRecordingRef.current;
if (recording === null || !beginAction()) return;
setError(null);
setState('validating');
try {
const recorder = await recordSession();
const input = await prepareRecordedAudio(recording, async () => {
await recorder.cleanup(recording);
});
@ -444,6 +496,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
const recording = recoveredRecordingRef.current;
if (recording === null || !beginAction()) return;
try {
const recorder = await recordSession();
await recorder.cleanup(recording);
recoveredRecordingRef.current = null;
const meetingId = activeMeetingIdRef.current;
@ -584,6 +637,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
}
if (!recorderActiveRef.current || !beginAction()) return;
try {
const recorder = await recordSession();
await recorder.cancel();
recorderActiveRef.current = false;
const meetingId = activeMeetingIdRef.current;

View file

@ -17,7 +17,12 @@ import { useI18n } from '@d3ro/i18n'
import { ThemeButton, ThemeText } from '../theme/themed-components'
import { useAuth } from '../lib/auth-context'
import { useMobilePreferences } from '../lib/preferences-context'
import { audioRecorder, type RecordingProgress } from '../lib/audio-recorder'
import {
audioRecorder,
type RecordedAudio,
type RecorderSession,
type RecordingProgress,
} from '../lib/audio-recorder'
import {
ChatServiceError,
type ChatErrorCode,
@ -25,6 +30,7 @@ import {
} from '../features/chat/chat-service'
import { AudioPipelineError } from '../features/import/audio-import-types'
import { streamTalkResponse } from '../features/talk/llm-stream-service'
import { isSameTalkAccount, talkAccountKey } from '../features/talk/talk-account'
import { transcribeTalkRecording } from '../features/talk/talk-transcription-service'
import {
shutdownTalkSpeech,
@ -128,6 +134,9 @@ export default function TalkScreen(): React.ReactElement {
const recordingStartingRef = useRef(false)
const recordingFinishingRef = useRef(false)
const recorderCleanupRef = useRef<Promise<void>>(Promise.resolve())
// Talk's own handle on the shared recorder. It can only stop or discard a
// capture Talk holds, never a meeting recording running on the Record tab.
const recorderSessionRef = useRef<RecorderSession | null>(null)
const pressHeldRef = useRef(false)
const textSendPendingRef = useRef(false)
const retryMessagesRef = useRef<TalkMessage[] | null>(null)
@ -197,8 +206,12 @@ export default function TalkScreen(): React.ReactElement {
if (assistantId !== null) {
updateMessages((current) => current.filter((message) => message.id !== assistantId))
}
if (recordingStartingRef.current || recordingActiveRef.current || recordingFinishingRef.current) {
const cleanup = audioRecorder.cancel().catch(() => undefined)
const recorderSession = recorderSessionRef.current
if (
recorderSession !== null
&& (recordingStartingRef.current || recordingActiveRef.current || recordingFinishingRef.current)
) {
const cleanup = recorderSession.cancel().catch(() => undefined)
recorderCleanupRef.current = cleanup
}
recordingStartingRef.current = false
@ -234,7 +247,7 @@ export default function TalkScreen(): React.ReactElement {
() => cancelActiveOperation(false)
), [cancelActiveOperation]))
const authIdentity = session === null ? 'signed-out' : `${session.user.id}:${session.access_token}`
const authIdentity = talkAccountKey(session)
const previousAuthIdentityRef = useRef(authIdentity)
useEffect(() => {
if (previousAuthIdentityRef.current !== authIdentity) {
@ -328,12 +341,7 @@ export default function TalkScreen(): React.ReactElement {
},
)
if (!isCurrentOperation(operationId)) return
const latestSession = sessionRef.current
if (
latestSession === null
|| latestSession.user.id !== currentSession.user.id
|| latestSession.access_token !== currentSession.access_token
) {
if (!isSameTalkAccount(currentSession, sessionRef.current)) {
throw new ChatServiceError('AUTH_REQUIRED', false, 401)
}
@ -395,18 +403,31 @@ export default function TalkScreen(): React.ReactElement {
}, [replaceMessages, runConversation])
const finishRecording = useCallback(async (operationId: number): Promise<void> => {
if (recordingFinishingRef.current || !recordingActiveRef.current) return
const recorderSession = recorderSessionRef.current
if (recordingFinishingRef.current || !recordingActiveRef.current || recorderSession === null) return
recordingFinishingRef.current = true
recordingActiveRef.current = false
try {
const recording = await audioRecorder.stop()
let recording: RecordedAudio
try {
recording = await recorderSession.stop()
} catch {
// The failed capture is Talk's own; discard it so the microphone is free again.
await recorderSession.cancel().catch(() => undefined)
if (isCurrentOperation(operationId)) {
setErrorCode('RECORDING_FAILED')
setCanRetry(false)
updatePhase('idle')
}
return
}
if (!isCurrentOperation(operationId)) {
await audioRecorder.cleanup(recording)
await recorderSession.cleanup(recording)
return
}
if (recording.durationMs < MIN_RECORDING_MS) {
await audioRecorder.cleanup(recording)
await recorderSession.cleanup(recording)
setErrorCode('RECORDING_TOO_SHORT')
updatePhase('idle')
return
@ -417,7 +438,7 @@ export default function TalkScreen(): React.ReactElement {
updatePhase('transcribing')
const currentSession = sessionRef.current
if (currentSession === null) {
await audioRecorder.cleanup(recording)
await recorderSession.cleanup(recording)
throw new AudioPipelineError('auth', 'Sign in to transcribe speech')
}
@ -425,16 +446,11 @@ export default function TalkScreen(): React.ReactElement {
accessToken: currentSession.access_token,
languageCode: preferences.locale,
signal: controller.signal,
disposeRecording: () => audioRecorder.cleanup(recording),
disposeRecording: () => recorderSession.cleanup(recording),
})
transcriptionControllerRef.current = null
if (!isCurrentOperation(operationId)) return
const latestSession = sessionRef.current
if (
latestSession === null
|| latestSession.user.id !== currentSession.user.id
|| latestSession.access_token !== currentSession.access_token
) {
if (!isSameTalkAccount(currentSession, sessionRef.current)) {
throw new AudioPipelineError('auth', 'The account changed during transcription')
}
setInput('')
@ -476,6 +492,7 @@ export default function TalkScreen(): React.ReactElement {
retryMessagesRef.current = null
await stopSpeech()
let recorderSession: RecorderSession | null = null
try {
await recorderCleanupRef.current
if (!isCurrentOperation(operationId)) return
@ -484,6 +501,11 @@ export default function TalkScreen(): React.ReactElement {
setErrorCode('AUTH_REQUIRED')
return
}
// Throws RecorderBusyError without side effects while a meeting
// recording on the Record tab holds the microphone.
recorderSession = await audioRecorder.acquire('talk')
recorderSessionRef.current = recorderSession
if (!isCurrentOperation(operationId)) return
const granted = await audioRecorder.requestPermission()
if (!isCurrentOperation(operationId)) return
if (!granted) {
@ -491,11 +513,11 @@ export default function TalkScreen(): React.ReactElement {
return
}
await audioRecorder.start((progress) => {
await recorderSession.start((progress) => {
if (isCurrentOperation(operationId)) setRecordingProgress(progress)
})
if (!isCurrentOperation(operationId)) {
await audioRecorder.cancel().catch(() => undefined)
await recorderSession.cancel().catch(() => undefined)
return
}
recordingActiveRef.current = true
@ -506,7 +528,9 @@ export default function TalkScreen(): React.ReactElement {
setErrorCode('RECORDING_FAILED')
updatePhase('idle')
}
await audioRecorder.cancel().catch(() => undefined)
// A session only discards a capture Talk holds; when acquisition failed
// there is nothing of Talk's to clean up.
if (recorderSession !== null) await recorderSession.cancel().catch(() => undefined)
} finally {
recordingStartingRef.current = false
}
@ -575,12 +599,7 @@ export default function TalkScreen(): React.ReactElement {
fixture = null
transcriptionControllerRef.current = null
if (!isCurrentOperation(operationId)) return
const latestSession = sessionRef.current
if (
latestSession === null ||
latestSession.user.id !== currentSession.user.id ||
latestSession.access_token !== currentSession.access_token
) {
if (!isSameTalkAccount(currentSession, sessionRef.current)) {
throw new AudioPipelineError('auth', 'The account changed during transcription')
}
submitMessage(result.text, true)