fix(mobile-recording): never lose a meeting capture on offline stop, oversize, or re-record
This commit is contained in:
parent
80538c0f23
commit
5b1621d314
9 changed files with 1238 additions and 56 deletions
18
apps/mobile-rn/src/features/import/audio-input-ownership.ts
Normal file
18
apps/mobile-rn/src/features/import/audio-input-ownership.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { LocalAudioInput } from './audio-import-types';
|
||||
|
||||
/**
|
||||
* Who may delete the original audio when a hand-off fails before the audio
|
||||
* is safely persisted somewhere else.
|
||||
*
|
||||
* A capture is the only copy of what the user said, so a failed hand-off must
|
||||
* leave it recoverable for the caller (Record keeps it as a recovered
|
||||
* recording the user can process again or discard explicitly). Picked and
|
||||
* shared files are app-owned temporary copies of a file the user still has
|
||||
* elsewhere, so they are cleaned up as before.
|
||||
*/
|
||||
export function mayDiscardOriginalOnFailure(source: LocalAudioInput['source']): boolean {
|
||||
return source !== 'recording';
|
||||
}
|
||||
|
||||
/** What prepareRecordedAudio does with the capture when validation rejects it. */
|
||||
export type InvalidRecordingPolicy = 'dispose' | 'retain';
|
||||
|
|
@ -1,10 +1,22 @@
|
|||
import type { RecordedAudio } from '../../lib/audio-recorder';
|
||||
import type { LocalAudioInput } from './audio-import-types';
|
||||
import type { InvalidRecordingPolicy } from './audio-input-ownership';
|
||||
import { validateLocalAudio } from './audio-validation';
|
||||
|
||||
export interface PrepareRecordedAudioOptions {
|
||||
/**
|
||||
* 'dispose' (default) deletes a capture that fails validation, which suits
|
||||
* ephemeral dictation (Talk). 'retain' leaves it untouched so the caller can
|
||||
* keep it recoverable: a long meeting over the upload size limit must not
|
||||
* be deleted the moment the user presses STOP.
|
||||
*/
|
||||
onInvalid?: InvalidRecordingPolicy;
|
||||
}
|
||||
|
||||
export async function prepareRecordedAudio(
|
||||
recording: RecordedAudio,
|
||||
dispose: () => Promise<void>,
|
||||
options: PrepareRecordedAudioOptions = {},
|
||||
): Promise<LocalAudioInput> {
|
||||
try {
|
||||
return await validateLocalAudio({
|
||||
|
|
@ -18,7 +30,7 @@ export async function prepareRecordedAudio(
|
|||
dispose,
|
||||
});
|
||||
} catch (error) {
|
||||
await dispose();
|
||||
if ((options.onInvalid ?? 'dispose') === 'dispose') await dispose();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,14 +8,15 @@ import {
|
|||
type LocalAudioInput,
|
||||
} from '../import/audio-import-types';
|
||||
import { processAudioInput } from '../import/audio-transcription-service';
|
||||
import {
|
||||
failMeetingRecording,
|
||||
markMeetingProcessingFailure,
|
||||
queueMeetingRecording,
|
||||
} from '../meetings/meetings-service';
|
||||
import { mayDiscardOriginalOnFailure } from '../import/audio-input-ownership';
|
||||
import { createUuidV4 } from '../../lib/random-id';
|
||||
import { createQueueRepository, type QueueRepository } from './durable-queue-repository';
|
||||
import { createRetryScheduler, type RetryScheduler } from './retry-scheduler';
|
||||
import {
|
||||
classifyMarkQueuedFailure,
|
||||
meetingsServiceLifecyclePort,
|
||||
type MeetingLifecyclePort,
|
||||
} from './meeting-lifecycle-port';
|
||||
|
||||
const STORAGE_KEY = '@d3ro/audio-processing-queue/v1';
|
||||
const QUEUE_DIRECTORY = `${Dirs.DocumentDir}/d3ro-processing-queue`;
|
||||
|
|
@ -51,6 +52,13 @@ export interface DurableQueueItem {
|
|||
lastErrorMessage: string | null;
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
/**
|
||||
* True while the server has not yet been told that this meeting capture
|
||||
* stopped and is waiting for processing. Absent on non-meeting items and on
|
||||
* items persisted before this field existed (their enqueue only kept the
|
||||
* item after that call had succeeded).
|
||||
*/
|
||||
meetingQueuePending?: boolean;
|
||||
}
|
||||
|
||||
export type DurableQueueEvent =
|
||||
|
|
@ -125,7 +133,8 @@ function parseQueue(raw: string | null): DurableQueueItem[] {
|
|||
(item.lastErrorCode === null || (typeof item.lastErrorCode === 'string' && item.lastErrorCode.length <= 64)) &&
|
||||
(item.lastErrorMessage === null || (typeof item.lastErrorMessage === 'string' && item.lastErrorMessage.length <= MAX_ERROR_LENGTH)) &&
|
||||
typeof item.createdAtMs === 'number' && Number.isFinite(item.createdAtMs) &&
|
||||
typeof item.updatedAtMs === 'number' && Number.isFinite(item.updatedAtMs)
|
||||
typeof item.updatedAtMs === 'number' && Number.isFinite(item.updatedAtMs) &&
|
||||
(item.meetingQueuePending === undefined || typeof item.meetingQueuePending === 'boolean')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -137,6 +146,8 @@ const repository: QueueRepository<DurableQueueItem> = createQueueRepository({
|
|||
parse: parseQueue,
|
||||
});
|
||||
|
||||
let meetingLifecycle: MeetingLifecyclePort = meetingsServiceLifecyclePort;
|
||||
|
||||
const scheduler: RetryScheduler = createRetryScheduler({
|
||||
now: () => Date.now(),
|
||||
setTimer: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||
|
|
@ -176,22 +187,34 @@ function fileExtension(fileName: string, mimeType: string): string {
|
|||
return 'audio';
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the audio durable and nothing else: copy into queue storage, verify
|
||||
* the copy, persist the item. The caller's original is released only after
|
||||
* the item is persisted. When persisting fails, a recording is left untouched
|
||||
* so the caller can keep it recoverable; an imported temporary copy is
|
||||
* cleaned up as before. Server state transitions happen later, in the
|
||||
* retryable processing step, so a network error can never undo a persisted
|
||||
* item.
|
||||
*/
|
||||
export async function enqueueAudioProcessing(
|
||||
input: LocalAudioInput,
|
||||
options: { userId: string; languageCode: MeetingLanguage; meetingId?: string },
|
||||
): Promise<DurableQueueItem> {
|
||||
const releaseOnFailure = async (): Promise<void> => {
|
||||
if (mayDiscardOriginalOnFailure(input.source)) await input.dispose();
|
||||
};
|
||||
if (!/^[0-9a-f-]{36}$/i.test(options.userId)) {
|
||||
await input.dispose();
|
||||
await releaseOnFailure();
|
||||
throw new AudioPipelineError('auth', 'A valid account is required for the processing queue');
|
||||
}
|
||||
if (options.meetingId !== undefined && !/^[0-9a-f-]{36}$/i.test(options.meetingId)) {
|
||||
await input.dispose();
|
||||
await releaseOnFailure();
|
||||
throw new AudioPipelineError('persist', 'Meeting id is invalid');
|
||||
}
|
||||
|
||||
const id = queueId();
|
||||
const target = `${QUEUE_DIRECTORY}/queued-${id}.${fileExtension(input.fileName, input.mimeType)}`;
|
||||
let persisted = false;
|
||||
let item: DurableQueueItem;
|
||||
try {
|
||||
await ensureQueueDirectory();
|
||||
await FileSystem.cp(input.path, target);
|
||||
|
|
@ -200,7 +223,7 @@ export async function enqueueAudioProcessing(
|
|||
throw new AudioPipelineError('file-read', 'Durable audio copy could not be verified');
|
||||
}
|
||||
const now = Date.now();
|
||||
const item: DurableQueueItem = {
|
||||
const created: DurableQueueItem = {
|
||||
schemaVersion: 1,
|
||||
id,
|
||||
userId: options.userId,
|
||||
|
|
@ -222,25 +245,19 @@ export async function enqueueAudioProcessing(
|
|||
lastErrorMessage: null,
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
...(options.meetingId !== undefined ? { meetingQueuePending: true } : {}),
|
||||
};
|
||||
await mutateQueue(items => [...items.filter(current => current.id !== id), item]);
|
||||
persisted = true;
|
||||
if (item.meetingId !== null) {
|
||||
await queueMeetingRecording(
|
||||
item.userId,
|
||||
item.meetingId,
|
||||
item.durationMs ?? 0,
|
||||
);
|
||||
}
|
||||
emit({ type: 'changed', item });
|
||||
return item;
|
||||
await mutateQueue(items => [...items.filter(current => current.id !== id), created]);
|
||||
item = created;
|
||||
} catch (error) {
|
||||
if (persisted) await mutateQueue(items => items.filter(item => item.id !== id));
|
||||
await removeOwnedFile(target).catch(() => undefined);
|
||||
await releaseOnFailure().catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
await input.dispose();
|
||||
}
|
||||
// The durable copy now owns the audio; the original is no longer needed.
|
||||
await input.dispose().catch(() => undefined);
|
||||
emit({ type: 'changed', item });
|
||||
return item;
|
||||
}
|
||||
|
||||
function queueInput(item: DurableQueueItem): LocalAudioInput {
|
||||
|
|
@ -286,33 +303,59 @@ async function reportMeetingFailure(
|
|||
terminal: boolean,
|
||||
): Promise<void> {
|
||||
if (item.meetingId === null) return;
|
||||
const meetingId = item.meetingId;
|
||||
const idempotencyKey = await FileSystem.hash(item.path, 'SHA-256')
|
||||
.then(hash => `mobile-meeting:${item.meetingId}:${hash.toLowerCase()}`)
|
||||
.then(hash => `mobile-meeting:${meetingId}:${hash.toLowerCase()}`)
|
||||
.catch(() => null);
|
||||
if (idempotencyKey !== null) {
|
||||
try {
|
||||
await markMeetingProcessingFailure(
|
||||
item.userId,
|
||||
item.meetingId,
|
||||
await meetingLifecycle.markProcessingFailure({
|
||||
userId: item.userId,
|
||||
meetingId,
|
||||
idempotencyKey,
|
||||
error.code,
|
||||
error.message.slice(0, MAX_ERROR_LENGTH),
|
||||
errorCode: error.code,
|
||||
errorMessage: error.message.slice(0, MAX_ERROR_LENGTH),
|
||||
terminal,
|
||||
);
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// Upload failures happen before the server processing job exists.
|
||||
}
|
||||
}
|
||||
if (terminal) {
|
||||
await failMeetingRecording(
|
||||
await meetingLifecycle.failRecording(
|
||||
item.userId,
|
||||
item.meetingId,
|
||||
meetingId,
|
||||
error.message.slice(0, MAX_ERROR_LENGTH),
|
||||
).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First, retryable processing step for a meeting capture: tell the server the
|
||||
* recording stopped and is queued. Runs until it succeeds once per item. A
|
||||
* reachable server that says the meeting is not recordable is not asked again
|
||||
* (see classifyMarkQueuedFailure), while an unreachable one leaves the item
|
||||
* for a later attempt instead of spending an upload on it.
|
||||
*/
|
||||
async function markMeetingQueued(item: DurableQueueItem): Promise<DurableQueueItem> {
|
||||
if (item.meetingId === null || item.meetingQueuePending !== true) return item;
|
||||
try {
|
||||
await meetingLifecycle.markQueued(item.userId, item.meetingId, item.durationMs ?? 0);
|
||||
} catch (cause) {
|
||||
if (classifyMarkQueuedFailure(cause) === 'retry') {
|
||||
throw new AudioPipelineError('persist', 'Meeting recording could not be marked as queued', cause);
|
||||
}
|
||||
}
|
||||
// Discarded while the call was in flight: do not resurrect the item.
|
||||
if (discardedItems.has(item.id)) {
|
||||
throw new AudioPipelineError('cancelled', 'Queued audio processing was cancelled');
|
||||
}
|
||||
const updated: DurableQueueItem = { ...item, meetingQueuePending: false, updatedAtMs: Date.now() };
|
||||
await replaceItem(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function processItemCore(item: DurableQueueItem): Promise<AudioPipelineResult> {
|
||||
if (!(await FileSystem.exists(item.path))) {
|
||||
throw new AudioPipelineError('file-read', 'Queued audio file is missing');
|
||||
|
|
@ -329,6 +372,7 @@ async function processItemCore(item: DurableQueueItem): Promise<AudioPipelineRes
|
|||
};
|
||||
await replaceItem(current);
|
||||
try {
|
||||
current = await markMeetingQueued(current);
|
||||
const result = await processAudioInput(queueInput(current), {
|
||||
expectedUserId: current.userId,
|
||||
languageCode: current.languageCode,
|
||||
|
|
@ -520,4 +564,11 @@ export const durableQueueTestContract = {
|
|||
retryDelayMs,
|
||||
ownedQueuePath,
|
||||
parseQueue,
|
||||
/** Swaps the meeting lifecycle port; returns a function restoring the default. */
|
||||
useMeetingLifecyclePort(port: MeetingLifecyclePort): () => void {
|
||||
meetingLifecycle = port;
|
||||
return () => {
|
||||
meetingLifecycle = meetingsServiceLifecyclePort;
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import {
|
||||
failMeetingRecording,
|
||||
markMeetingProcessingFailure,
|
||||
queueMeetingRecording,
|
||||
} from '../meetings/meetings-service';
|
||||
|
||||
/**
|
||||
* Server-side meeting state transitions the durable queue needs. The queue
|
||||
* owns the local audio; these calls only tell the server what happened to it,
|
||||
* so none of them may decide whether the local audio survives.
|
||||
*/
|
||||
export interface MeetingLifecyclePort {
|
||||
/** Idempotent: recording -> processing with the captured duration. */
|
||||
markQueued(userId: string, meetingId: string, durationMs: number): Promise<void>;
|
||||
markProcessingFailure(failure: {
|
||||
userId: string;
|
||||
meetingId: string;
|
||||
idempotencyKey: string;
|
||||
errorCode: string;
|
||||
errorMessage: string;
|
||||
terminal: boolean;
|
||||
}): Promise<void>;
|
||||
failRecording(userId: string, meetingId: string, errorMessage: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const meetingsServiceLifecyclePort: MeetingLifecyclePort = {
|
||||
async markQueued(userId, meetingId, durationMs) {
|
||||
await queueMeetingRecording(userId, meetingId, durationMs);
|
||||
},
|
||||
async markProcessingFailure(failure) {
|
||||
await markMeetingProcessingFailure(
|
||||
failure.userId,
|
||||
failure.meetingId,
|
||||
failure.idempotencyKey,
|
||||
failure.errorCode,
|
||||
failure.errorMessage,
|
||||
failure.terminal,
|
||||
);
|
||||
},
|
||||
async failRecording(userId, meetingId, errorMessage) {
|
||||
await failMeetingRecording(userId, meetingId, errorMessage);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* What a failed markQueued means for the queued item.
|
||||
*
|
||||
* - 'retry': the server could not be reached or refused the session for now
|
||||
* (network, auth, server, conflict). Keep the item and try again later;
|
||||
* uploading now would fail for the same reason.
|
||||
* - 'proceed': the server answered that the meeting is not in a recordable
|
||||
* state (validation, forbidden, not-found), e.g. after this item already
|
||||
* failed terminally once and the user retries it. Marking it queued will
|
||||
* never succeed, so stop asking and let processing, whose begin step is the
|
||||
* authoritative transition, decide.
|
||||
*/
|
||||
export type MeetingQueueFailurePolicy = 'retry' | 'proceed';
|
||||
|
||||
const NOT_RECORDABLE_CODES: ReadonlySet<string> = new Set(['validation', 'forbidden', 'not-found']);
|
||||
|
||||
export function classifyMarkQueuedFailure(error: unknown): MeetingQueueFailurePolicy {
|
||||
// Structural check: meetings-service maps every RPC failure to a
|
||||
// MeetingServiceError whose `code` is one of its MeetingServiceErrorCode.
|
||||
if (!(error instanceof Error) || error.name !== 'MeetingServiceError') return 'retry';
|
||||
const code: unknown = (error as Error & { code?: unknown }).code;
|
||||
return typeof code === 'string' && NOT_RECORDABLE_CODES.has(code) ? 'proceed' : 'retry';
|
||||
}
|
||||
|
|
@ -36,6 +36,7 @@ import {
|
|||
type LocalAudioInput,
|
||||
} from '../features/import/audio-import-types';
|
||||
import { prepareRecordedAudio } from '../features/import/recorded-audio-input';
|
||||
import { mayDiscardOriginalOnFailure } from '../features/import/audio-input-ownership';
|
||||
import { useMobilePreferences } from '../lib/preferences-context';
|
||||
import { showLocalTranscriptionCompleted } from '../features/notifications/notification-native';
|
||||
import {
|
||||
|
|
@ -115,10 +116,18 @@ function isPipelineState(state: RecordingState): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
interface RecordScreenMeetingParams {
|
||||
meetingId?: string;
|
||||
meetingTitle?: string;
|
||||
meetingLanguage?: string;
|
||||
}
|
||||
|
||||
interface RecordScreenProps {
|
||||
route: { params?: { meetingId?: string; meetingTitle?: string; meetingLanguage?: string; incomingMediaId?: string } }
|
||||
route: { params?: RecordScreenMeetingParams & { incomingMediaId?: string } }
|
||||
navigation: {
|
||||
getParent: () => { navigate: (name: 'MeetingDetail', params: { meetingId: string }) => void } | undefined
|
||||
/** Clears the meeting binding once its capture is committed. */
|
||||
setParams?: (params: RecordScreenMeetingParams) => void
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -139,6 +148,8 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
|||
const [actionPending, setActionPending] = useState(false);
|
||||
const [sharePending, setSharePending] = useState(false);
|
||||
const [pendingIncomingMediaId, setPendingIncomingMediaId] = useState<string | null>(null);
|
||||
// Meeting whose capture produced the result on screen (for "view meeting").
|
||||
const [completedMeetingId, setCompletedMeetingId] = useState<string | null>(null);
|
||||
const actionPendingRef = useRef(false);
|
||||
const mountedRef = useRef(true);
|
||||
const recorderActiveRef = useRef(false);
|
||||
|
|
@ -146,12 +157,38 @@ 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);
|
||||
// Meeting the next START binds to. MeetingDetail arms it through route
|
||||
// params; it is released once that meeting's capture is committed, so a
|
||||
// later "NEW RECORDING" cannot re-begin (and restart) a finished meeting.
|
||||
const armedMeetingIdRef = useRef<string | null>(route.params?.meetingId ?? null);
|
||||
// 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>());
|
||||
// Latest route binding, read by callbacks registered on an earlier render.
|
||||
const routeMeetingIdRef = useRef<string | null>(routeMeetingId);
|
||||
routeMeetingIdRef.current = routeMeetingId;
|
||||
|
||||
/**
|
||||
* A meeting binding covers one capture. Once the capture's transcript is
|
||||
* committed, Record falls back to plain recording and the route params are
|
||||
* cleared, so returning through the tab bar does not re-arm the meeting.
|
||||
* MeetingDetail re-arms it explicitly ("record again") with fresh params.
|
||||
*/
|
||||
function releaseMeetingBinding(meetingId: string | null): void {
|
||||
setCompletedMeetingId(meetingId);
|
||||
if (meetingId === null) return;
|
||||
if (armedMeetingIdRef.current === meetingId) armedMeetingIdRef.current = null;
|
||||
if (activeMeetingIdRef.current === meetingId) activeMeetingIdRef.current = null;
|
||||
if (routeMeetingIdRef.current === meetingId) {
|
||||
navigation.setParams?.({ meetingId: undefined, meetingTitle: undefined, meetingLanguage: undefined });
|
||||
}
|
||||
}
|
||||
// Latest release callback, for subscriptions registered on an earlier render.
|
||||
const releaseMeetingBindingRef = useRef(releaseMeetingBinding);
|
||||
releaseMeetingBindingRef.current = releaseMeetingBinding;
|
||||
|
||||
/**
|
||||
* Record owns meeting captures, including one the Android foreground service
|
||||
|
|
@ -222,6 +259,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
|||
if (event.itemId !== queueItemIdRef.current || !mountedRef.current) return;
|
||||
queueItemIdRef.current = null;
|
||||
operationUserRef.current = null;
|
||||
releaseMeetingBindingRef.current(event.result.meetingId ?? activeMeetingIdRef.current);
|
||||
setTranscript(event.result.transcript);
|
||||
setProvider(event.result.provider);
|
||||
setDurationMs(Math.max(0, Math.round(event.result.durationSeconds * 1_000)));
|
||||
|
|
@ -254,6 +292,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
|||
}, [user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
armedMeetingIdRef.current = routeMeetingId;
|
||||
if (!recorderActiveRef.current && recoveredRecordingRef.current === null) {
|
||||
activeMeetingIdRef.current = routeMeetingId;
|
||||
}
|
||||
|
|
@ -275,19 +314,29 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
|||
if (mountedRef.current) setActionPending(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the input to the durable queue and processes it. `onPersisted` runs
|
||||
* once the queue owns its own copy; before that, a recording is still the
|
||||
* caller's only copy and must not be deleted on failure.
|
||||
*/
|
||||
async function processInput(
|
||||
input: LocalAudioInput,
|
||||
meetingId: string | null = activeMeetingIdRef.current,
|
||||
onPersisted?: () => void,
|
||||
): Promise<void> {
|
||||
if (user === null) {
|
||||
await input.dispose();
|
||||
if (mayDiscardOriginalOnFailure(input.source)) await input.dispose();
|
||||
throw new AudioPipelineError('auth', t('mobile.rec.loginRequired'));
|
||||
}
|
||||
|
||||
operationUserRef.current = user.id;
|
||||
const routeLanguage = route.params?.meetingLanguage;
|
||||
const routeLanguage = meetingId !== null && meetingId === route.params?.meetingId
|
||||
? route.params?.meetingLanguage
|
||||
: undefined;
|
||||
// The language lookup is an optimisation, not a precondition: offline it
|
||||
// falls back to the app locale instead of blocking the hand-off.
|
||||
const storedLanguage = meetingId !== null && routeLanguage === undefined
|
||||
? await getMeetingRecordingLanguage(user.id, meetingId)
|
||||
? await getMeetingRecordingLanguage(user.id, meetingId).catch(() => null)
|
||||
: null;
|
||||
const queued = await enqueueAudioProcessing(input, {
|
||||
userId: user.id,
|
||||
|
|
@ -295,9 +344,11 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
|||
meetingId: meetingId ?? undefined,
|
||||
});
|
||||
queueItemIdRef.current = queued.id;
|
||||
onPersisted?.();
|
||||
try {
|
||||
const result = await processQueuedAudioNow(user.id, queued.id);
|
||||
if (!mountedRef.current) return;
|
||||
releaseMeetingBinding(result.meetingId ?? meetingId);
|
||||
setTranscript(result.transcript);
|
||||
setProvider(result.provider);
|
||||
setDurationMs(Math.max(0, Math.round(result.durationSeconds * 1000)));
|
||||
|
|
@ -335,9 +386,11 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
|||
setDeduplicated(false);
|
||||
setDurationMs(0);
|
||||
setMeteringDb(null);
|
||||
setCompletedMeetingId(null);
|
||||
recoveredRecordingRef.current = null;
|
||||
setState('starting');
|
||||
recorderActiveRef.current = true;
|
||||
const meetingToBegin = armedMeetingIdRef.current;
|
||||
let meetingBegun = false;
|
||||
try {
|
||||
// Re-check on every start (not the cached handle) so a busy recorder is
|
||||
|
|
@ -352,11 +405,11 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
|||
setState('error');
|
||||
return;
|
||||
}
|
||||
if (routeMeetingId !== null) {
|
||||
if (meetingToBegin !== null) {
|
||||
if (user === null) throw new AudioPipelineError('auth', t('mobile.rec.loginRequired'));
|
||||
await beginMeetingRecording(user.id, routeMeetingId);
|
||||
await beginMeetingRecording(user.id, meetingToBegin);
|
||||
meetingBegun = true;
|
||||
activeMeetingIdRef.current = routeMeetingId;
|
||||
activeMeetingIdRef.current = meetingToBegin;
|
||||
} else {
|
||||
activeMeetingIdRef.current = null;
|
||||
}
|
||||
|
|
@ -367,10 +420,10 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
|||
if (mountedRef.current) setState('recording');
|
||||
} catch (recordingError) {
|
||||
recorderActiveRef.current = false;
|
||||
if (meetingBegun && user !== null && routeMeetingId !== null) {
|
||||
if (meetingBegun && user !== null && meetingToBegin !== null) {
|
||||
await failMeetingRecording(
|
||||
user.id,
|
||||
routeMeetingId,
|
||||
meetingToBegin,
|
||||
errorMessage(recordingError, t('mobile.rec.startFailed')),
|
||||
).catch(() => undefined);
|
||||
}
|
||||
|
|
@ -428,10 +481,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
|||
}
|
||||
setDurationMs(recording.durationMs);
|
||||
setFileName(recording.fileName);
|
||||
const input = await prepareRecordedAudio(recording, async () => {
|
||||
await recorder.cleanup(recording);
|
||||
});
|
||||
await processInput(input, activeMeetingIdRef.current);
|
||||
await handOffRecording(recorder, recording);
|
||||
} catch (recordingError) {
|
||||
recorderActiveRef.current = false;
|
||||
if (!mountedRef.current) return;
|
||||
|
|
@ -471,6 +521,40 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
|||
if (snapshot === null || snapshot.state === 'idle') setState('error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands a finished capture to the durable queue. Until the queue has
|
||||
* persisted its own copy the capture is the only copy, so a failure before
|
||||
* that point (too large to upload, unreadable, no session, storage full)
|
||||
* keeps it as a recovered recording the user can process again or discard
|
||||
* explicitly, instead of deleting it. Failures after that point belong to
|
||||
* the queue item and are rethrown.
|
||||
*/
|
||||
async function handOffRecording(
|
||||
recorder: RecorderSession,
|
||||
recording: RecordedAudio,
|
||||
): Promise<void> {
|
||||
let persisted = false;
|
||||
try {
|
||||
const input = await prepareRecordedAudio(
|
||||
recording,
|
||||
async () => {
|
||||
await recorder.cleanup(recording);
|
||||
},
|
||||
{ onInvalid: 'retain' },
|
||||
);
|
||||
await processInput(input, activeMeetingIdRef.current, () => {
|
||||
persisted = true;
|
||||
});
|
||||
} catch (handOffError) {
|
||||
if (persisted) throw handOffError;
|
||||
recoveredRecordingRef.current = recording;
|
||||
if (!mountedRef.current) return;
|
||||
setMeteringDb(null);
|
||||
setError(errorMessage(handOffError, t('mobile.rec.processFailed')));
|
||||
setState('recoverable');
|
||||
}
|
||||
}
|
||||
|
||||
async function processRecoveredRecording(): Promise<void> {
|
||||
const recording = recoveredRecordingRef.current;
|
||||
if (recording === null || !beginAction()) return;
|
||||
|
|
@ -478,11 +562,8 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
|||
setState('validating');
|
||||
try {
|
||||
const recorder = await recordSession();
|
||||
const input = await prepareRecordedAudio(recording, async () => {
|
||||
await recorder.cleanup(recording);
|
||||
});
|
||||
recoveredRecordingRef.current = null;
|
||||
await processInput(input, activeMeetingIdRef.current);
|
||||
await handOffRecording(recorder, recording);
|
||||
} catch (processingError) {
|
||||
if (!mountedRef.current) return;
|
||||
setError(errorMessage(processingError, t('mobile.rec.processFailed')));
|
||||
|
|
@ -503,7 +584,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
|||
if (meetingId !== null && user !== null) {
|
||||
await cancelMeetingRecording(user.id, meetingId).catch(() => undefined);
|
||||
}
|
||||
activeMeetingIdRef.current = routeMeetingId;
|
||||
activeMeetingIdRef.current = armedMeetingIdRef.current;
|
||||
if (mountedRef.current) {
|
||||
setState('idle');
|
||||
setError(null);
|
||||
|
|
@ -526,6 +607,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
|||
let selectedForProcessing = false;
|
||||
setError(null);
|
||||
setState('selecting');
|
||||
setCompletedMeetingId(null);
|
||||
try {
|
||||
const input = await pickAudioFile();
|
||||
selectedForProcessing = true;
|
||||
|
|
@ -566,6 +648,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
|||
const previousState = state;
|
||||
setError(null);
|
||||
setState('selecting');
|
||||
setCompletedMeetingId(null);
|
||||
try {
|
||||
const input = await consumeIncomingMediaAudio();
|
||||
if (input === null) {
|
||||
|
|
@ -644,7 +727,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
|||
if (meetingId !== null && user !== null) {
|
||||
await cancelMeetingRecording(user.id, meetingId).catch(() => undefined);
|
||||
}
|
||||
activeMeetingIdRef.current = routeMeetingId;
|
||||
activeMeetingIdRef.current = armedMeetingIdRef.current;
|
||||
if (!mountedRef.current) return;
|
||||
setState('idle');
|
||||
setTranscript('');
|
||||
|
|
@ -830,15 +913,14 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
|||
/>
|
||||
</>
|
||||
)}
|
||||
{state === 'done' && activeMeetingIdRef.current !== null && (
|
||||
{state === 'done' && completedMeetingId !== null && (
|
||||
<>
|
||||
<View style={styles.buttonSpacer} />
|
||||
<PhysicalButton
|
||||
label={t('mobile.rec.viewMeeting')}
|
||||
variant="secondary"
|
||||
onPress={() => {
|
||||
const meetingId = activeMeetingIdRef.current;
|
||||
if (meetingId !== null) navigation.getParent()?.navigate('MeetingDetail', { meetingId });
|
||||
navigation.getParent()?.navigate('MeetingDetail', { meetingId: completedMeetingId });
|
||||
}}
|
||||
testID="record-view-meeting"
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue