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

@ -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)