fix(mobile-recorder): scope recorder control to its owner and keep Android recordings on stop failure
This commit is contained in:
parent
72c4ddaefb
commit
ace946d5c7
10 changed files with 1673 additions and 697 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue