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
292
apps/mobile-rn/__tests__/audio-recorder-redteam-r1-24.test.ts
Normal file
292
apps/mobile-rn/__tests__/audio-recorder-redteam-r1-24.test.ts
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
import { NativeModules } from 'react-native'
|
||||||
|
import {
|
||||||
|
AudioRecorder,
|
||||||
|
RecorderBusyError,
|
||||||
|
type RecorderBackend,
|
||||||
|
type RecordingRuntimeSnapshot,
|
||||||
|
} from '../src/lib/audio-recorder'
|
||||||
|
import { AndroidForegroundRecorderBackend } from '../src/lib/recorder/android-foreground-backend'
|
||||||
|
|
||||||
|
const MEETING_PATH = '/data/user/0/com.d3ro.voice/files/recordings/d3ro-recording-11111111-1111-4111-8111-111111111111.wav'
|
||||||
|
|
||||||
|
type NativeState = 'idle' | 'starting' | 'recording' | 'paused' | 'stopped' | 'recoverable'
|
||||||
|
|
||||||
|
function nativeSnapshot(state: NativeState, size = 64_044, path: string | null = MEETING_PATH) {
|
||||||
|
const hasFile = state !== 'idle' && path !== null
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
path: hasFile ? path : null,
|
||||||
|
uri: hasFile ? `file://${path}` : null,
|
||||||
|
fileName: hasFile ? path!.slice(path!.lastIndexOf('/') + 1) : null,
|
||||||
|
mimeType: 'audio/wav',
|
||||||
|
size: hasFile ? size : 0,
|
||||||
|
durationMs: hasFile ? 4_000 : 0,
|
||||||
|
meteringDb: null,
|
||||||
|
meetingId: null,
|
||||||
|
interruptionReason: null,
|
||||||
|
startedAtMs: hasFile ? 1_000 : 0,
|
||||||
|
recoverable: state === 'recoverable',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FakeNativeModule {
|
||||||
|
current: ReturnType<typeof nativeSnapshot>
|
||||||
|
getStatus: jest.Mock
|
||||||
|
start: jest.Mock
|
||||||
|
pause: jest.Mock
|
||||||
|
resume: jest.Mock
|
||||||
|
stop: jest.Mock
|
||||||
|
cancel: jest.Mock
|
||||||
|
dispose: jest.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
function installNativeModule(initial: NativeState = 'idle'): FakeNativeModule {
|
||||||
|
const native: FakeNativeModule = {
|
||||||
|
current: nativeSnapshot(initial),
|
||||||
|
getStatus: jest.fn(async () => native.current),
|
||||||
|
start: jest.fn(async () => {
|
||||||
|
if (native.current.state !== 'idle') {
|
||||||
|
throw Object.assign(new Error('Finish or discard the existing recording before starting another'), {
|
||||||
|
code: 'recording-already-exists',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
native.current = nativeSnapshot('recording')
|
||||||
|
return native.current
|
||||||
|
}),
|
||||||
|
pause: jest.fn(async () => {
|
||||||
|
native.current = nativeSnapshot('paused')
|
||||||
|
return native.current
|
||||||
|
}),
|
||||||
|
resume: jest.fn(async () => {
|
||||||
|
native.current = nativeSnapshot('recording')
|
||||||
|
return native.current
|
||||||
|
}),
|
||||||
|
stop: jest.fn(async () => {
|
||||||
|
native.current = nativeSnapshot('stopped')
|
||||||
|
return native.current
|
||||||
|
}),
|
||||||
|
cancel: jest.fn(async () => {
|
||||||
|
native.current = nativeSnapshot('idle')
|
||||||
|
return native.current
|
||||||
|
}),
|
||||||
|
dispose: jest.fn(async (path: string) => {
|
||||||
|
if (native.current.path === path) native.current = nativeSnapshot('idle')
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
;(NativeModules as Record<string, unknown>).D3RORecording = native
|
||||||
|
return native
|
||||||
|
}
|
||||||
|
|
||||||
|
function androidRecorder(): AudioRecorder {
|
||||||
|
return new AudioRecorder(new AndroidForegroundRecorderBackend())
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllTimers()
|
||||||
|
jest.useRealTimers()
|
||||||
|
delete (NativeModules as Record<string, unknown>).D3RORecording
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('recorder ownership lease (redteam r1-24 bug 1)', () => {
|
||||||
|
test('Talk cannot acquire while a Record meeting capture runs, and the capture survives', async () => {
|
||||||
|
const native = installNativeModule()
|
||||||
|
const recorder = androidRecorder()
|
||||||
|
const record = await recorder.acquire('record')
|
||||||
|
await record.start(() => undefined, { meetingId: '22222222-2222-4222-8222-222222222222' })
|
||||||
|
|
||||||
|
await expect(recorder.acquire('talk')).rejects.toBeInstanceOf(RecorderBusyError)
|
||||||
|
|
||||||
|
expect(native.cancel).not.toHaveBeenCalled()
|
||||||
|
expect(native.dispose).not.toHaveBeenCalled()
|
||||||
|
expect(native.current.state).toBe('recording')
|
||||||
|
// Record still owns and can finish its capture.
|
||||||
|
await expect(record.stop()).resolves.toMatchObject({ path: MEETING_PATH })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a Talk session never cancels a capture it does not hold', async () => {
|
||||||
|
const native = installNativeModule()
|
||||||
|
const recorder = androidRecorder()
|
||||||
|
const talk = await recorder.acquire('talk')
|
||||||
|
const record = await recorder.acquire('record')
|
||||||
|
await record.start(() => undefined)
|
||||||
|
|
||||||
|
await expect(talk.start(() => undefined)).rejects.toBeInstanceOf(RecorderBusyError)
|
||||||
|
await talk.cancel()
|
||||||
|
await talk.stop().catch(() => undefined)
|
||||||
|
|
||||||
|
expect(native.cancel).not.toHaveBeenCalled()
|
||||||
|
expect(native.stop).not.toHaveBeenCalled()
|
||||||
|
expect(native.current.state).toBe('recording')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a native capture that outlived the JS process is treated as Record-owned', async () => {
|
||||||
|
const native = installNativeModule('recording')
|
||||||
|
const recorder = androidRecorder()
|
||||||
|
|
||||||
|
await expect(recorder.acquire('talk')).rejects.toMatchObject({ holder: 'record' })
|
||||||
|
expect(native.cancel).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
const record = await recorder.acquire('record')
|
||||||
|
const snapshot = await record.restore()
|
||||||
|
expect(snapshot.state).toBe('recording')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a recoverable meeting file also blocks Talk instead of being discarded', async () => {
|
||||||
|
const native = installNativeModule('recoverable')
|
||||||
|
const recorder = androidRecorder()
|
||||||
|
|
||||||
|
await expect(recorder.acquire('talk')).rejects.toBeInstanceOf(RecorderBusyError)
|
||||||
|
expect(native.cancel).not.toHaveBeenCalled()
|
||||||
|
expect(native.current.state).toBe('recoverable')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Talk can use the recorder again once the meeting capture is disposed', async () => {
|
||||||
|
installNativeModule()
|
||||||
|
const recorder = androidRecorder()
|
||||||
|
const record = await recorder.acquire('record')
|
||||||
|
await record.start(() => undefined)
|
||||||
|
const recording = await record.stop()
|
||||||
|
await record.cleanup(recording)
|
||||||
|
|
||||||
|
const talk = await recorder.acquire('talk')
|
||||||
|
await expect(talk.start(() => undefined)).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the privileged account-purge cancel still discards any capture', async () => {
|
||||||
|
const native = installNativeModule()
|
||||||
|
const recorder = androidRecorder()
|
||||||
|
const record = await recorder.acquire('record')
|
||||||
|
await record.start(() => undefined)
|
||||||
|
|
||||||
|
await recorder.cancel()
|
||||||
|
|
||||||
|
expect(native.cancel).toHaveBeenCalledTimes(1)
|
||||||
|
await expect(recorder.acquire('talk')).resolves.toMatchObject({ owner: 'talk' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the lease is enforced for any backend, not only Android', async () => {
|
||||||
|
let active = false
|
||||||
|
const backend: RecorderBackend = {
|
||||||
|
requestPermission: jest.fn(async () => true),
|
||||||
|
start: jest.fn(async () => { active = true }),
|
||||||
|
pause: jest.fn(async () => undefined),
|
||||||
|
resume: jest.fn(async () => undefined),
|
||||||
|
stop: jest.fn(async () => ({
|
||||||
|
uri: 'file:///cache/a.m4a',
|
||||||
|
path: '/cache/a.m4a',
|
||||||
|
fileName: 'a.m4a',
|
||||||
|
mimeType: 'audio/mp4',
|
||||||
|
size: 10,
|
||||||
|
durationMs: 1_000,
|
||||||
|
})),
|
||||||
|
cleanup: jest.fn(async () => { active = false }),
|
||||||
|
cancel: jest.fn(async () => { active = false }),
|
||||||
|
restore: jest.fn(async () => ({
|
||||||
|
state: 'idle' as const,
|
||||||
|
recording: null,
|
||||||
|
meetingId: null,
|
||||||
|
interruptionReason: null,
|
||||||
|
startedAtMs: 0,
|
||||||
|
})),
|
||||||
|
isBusy: jest.fn(async () => active),
|
||||||
|
hasInProcessCapture: jest.fn(() => active),
|
||||||
|
}
|
||||||
|
const recorder = new AudioRecorder(backend)
|
||||||
|
const earlierTalk = await recorder.acquire('talk')
|
||||||
|
const record = await recorder.acquire('record')
|
||||||
|
await record.start(() => undefined)
|
||||||
|
|
||||||
|
await expect(recorder.acquire('talk')).rejects.toBeInstanceOf(RecorderBusyError)
|
||||||
|
await earlierTalk.cancel()
|
||||||
|
expect(backend.cancel).not.toHaveBeenCalled()
|
||||||
|
await record.cancel()
|
||||||
|
expect(backend.cancel).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Android stop failure keeps the recording recoverable (redteam r1-24 bug 3)', () => {
|
||||||
|
test('a native stop timeout surfaces the native error and reports the recoverable file', async () => {
|
||||||
|
const native = installNativeModule()
|
||||||
|
const recorder = androidRecorder()
|
||||||
|
const record = await recorder.acquire('record')
|
||||||
|
const states: RecordingRuntimeSnapshot[] = []
|
||||||
|
await record.start(() => undefined, { onStateChange: (snapshot) => states.push(snapshot) })
|
||||||
|
|
||||||
|
const timeout = Object.assign(new Error('Recording could not be stopped in time'), {
|
||||||
|
code: 'recording-stop-timeout',
|
||||||
|
})
|
||||||
|
native.stop.mockImplementationOnce(async () => {
|
||||||
|
// The service keeps finalizing after the command times out.
|
||||||
|
native.current = nativeSnapshot('recoverable')
|
||||||
|
throw timeout
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(record.stop()).rejects.toBe(timeout)
|
||||||
|
|
||||||
|
expect(states.at(-1)).toMatchObject({
|
||||||
|
state: 'recoverable',
|
||||||
|
recording: { path: MEETING_PATH },
|
||||||
|
})
|
||||||
|
expect(native.dispose).not.toHaveBeenCalled()
|
||||||
|
expect(native.cancel).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
// The recovered file can be discarded and the recorder is usable again.
|
||||||
|
await record.cleanup({ path: MEETING_PATH })
|
||||||
|
expect(native.dispose).toHaveBeenCalledWith(MEETING_PATH)
|
||||||
|
await expect(record.start(() => undefined)).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a header-only WAV reports the stopped file instead of masking the error', async () => {
|
||||||
|
const native = installNativeModule()
|
||||||
|
const recorder = androidRecorder()
|
||||||
|
const record = await recorder.acquire('record')
|
||||||
|
const states: RecordingRuntimeSnapshot[] = []
|
||||||
|
await record.start(() => undefined, { onStateChange: (snapshot) => states.push(snapshot) })
|
||||||
|
native.stop.mockImplementationOnce(async () => {
|
||||||
|
native.current = nativeSnapshot('stopped', 44)
|
||||||
|
return native.current
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(record.stop()).rejects.toThrow('Android recorder produced no recoverable audio')
|
||||||
|
expect(states.at(-1)).toMatchObject({ state: 'stopped' })
|
||||||
|
|
||||||
|
// A later poll must not be able to wedge the recorder in "stopped".
|
||||||
|
await jest.runOnlyPendingTimersAsync()
|
||||||
|
await record.cleanup({ path: MEETING_PATH })
|
||||||
|
await expect(record.start(() => undefined)).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unreadable status after a failed stop keeps the capture instead of dropping it', async () => {
|
||||||
|
const native = installNativeModule()
|
||||||
|
const recorder = androidRecorder()
|
||||||
|
const record = await recorder.acquire('record')
|
||||||
|
await record.start(() => undefined)
|
||||||
|
const failure = Object.assign(new Error('Only an active recording can be stopped'), {
|
||||||
|
code: 'recording-invalid-state',
|
||||||
|
})
|
||||||
|
native.stop.mockRejectedValueOnce(failure)
|
||||||
|
native.getStatus.mockRejectedValueOnce(new Error('bridge unavailable'))
|
||||||
|
|
||||||
|
await expect(record.stop()).rejects.toBe(failure)
|
||||||
|
|
||||||
|
// The capture is still Record's and can still be stopped normally.
|
||||||
|
await expect(recorder.acquire('talk')).rejects.toBeInstanceOf(RecorderBusyError)
|
||||||
|
await expect(record.stop()).resolves.toMatchObject({ path: MEETING_PATH })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('disposing an older file does not reset a capture that is running now', async () => {
|
||||||
|
const native = installNativeModule()
|
||||||
|
const recorder = androidRecorder()
|
||||||
|
const record = await recorder.acquire('record')
|
||||||
|
await record.start(() => undefined)
|
||||||
|
|
||||||
|
await record.cleanup({ path: '/data/user/0/com.d3ro.voice/files/recordings/older.wav' })
|
||||||
|
|
||||||
|
await expect(record.stop()).resolves.toMatchObject({ path: MEETING_PATH })
|
||||||
|
expect(native.stop).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
238
apps/mobile-rn/__tests__/talk-screen-redteam-r1-24.test.tsx
Normal file
238
apps/mobile-rn/__tests__/talk-screen-redteam-r1-24.test.tsx
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||||
|
|
||||||
|
const USER_ID = '11111111-1111-4111-8111-111111111111'
|
||||||
|
|
||||||
|
let mockSession: { user: { id: string }, access_token: string } | null = {
|
||||||
|
user: { id: USER_ID },
|
||||||
|
access_token: 'token-1',
|
||||||
|
}
|
||||||
|
const mockAcquire = jest.fn()
|
||||||
|
const mockRequestPermission = jest.fn(async () => true)
|
||||||
|
const mockLegacyStart = jest.fn(async () => {
|
||||||
|
throw new Error('Cannot start recorder while it is recording')
|
||||||
|
})
|
||||||
|
const mockGlobalCancel = jest.fn(async () => undefined)
|
||||||
|
const mockStreamTalkResponse = jest.fn()
|
||||||
|
|
||||||
|
jest.mock('@react-navigation/native', () => {
|
||||||
|
const ReactValue = require('react') as typeof React
|
||||||
|
return {
|
||||||
|
useFocusEffect: (effect: () => void | (() => void)) => {
|
||||||
|
ReactValue.useEffect(effect, [effect])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
jest.mock('react-native-safe-area-context', () => ({
|
||||||
|
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
|
||||||
|
}))
|
||||||
|
jest.mock('@d3ro/i18n', () => ({
|
||||||
|
useI18n: () => ({ t: (key: string) => key }),
|
||||||
|
}))
|
||||||
|
jest.mock('../src/lib/auth-context', () => ({
|
||||||
|
useAuth: () => ({ session: mockSession }),
|
||||||
|
}))
|
||||||
|
jest.mock('../src/lib/preferences-context', () => ({
|
||||||
|
useMobilePreferences: () => ({
|
||||||
|
palette: {
|
||||||
|
bg: { app: 'app', sidebar: 'sidebar', card: 'card', inset: 'inset' },
|
||||||
|
text: { primary: 'primary', muted: 'muted' },
|
||||||
|
border: { default: 'border' },
|
||||||
|
accent: { main: 'accent', dim: 'dim', pressed: 'pressed' },
|
||||||
|
tag: { red: 'red' },
|
||||||
|
},
|
||||||
|
preferences: { preferredLlmModel: 'default', locale: 'en' },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
jest.mock('../src/theme/themed-components', () => {
|
||||||
|
const { Pressable, Text } = require('react-native') as typeof import('react-native')
|
||||||
|
return {
|
||||||
|
ThemeText: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||||
|
<Text {...props}>{children}</Text>
|
||||||
|
),
|
||||||
|
ThemeButton: ({ label, onPress, ...props }: { label: string, onPress?: () => void }) => (
|
||||||
|
<Pressable {...props} onPress={onPress}>
|
||||||
|
<Text>{label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
jest.mock('../src/components/ContentReportSheet', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: () => null,
|
||||||
|
}))
|
||||||
|
jest.mock('../src/features/chat/chat-service', () => {
|
||||||
|
class MockChatServiceError extends Error {
|
||||||
|
code: string
|
||||||
|
retryable: boolean
|
||||||
|
|
||||||
|
constructor(mockCode: string, mockRetryable: boolean) {
|
||||||
|
super(mockCode)
|
||||||
|
this.code = mockCode
|
||||||
|
this.retryable = mockRetryable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ChatServiceError: MockChatServiceError }
|
||||||
|
})
|
||||||
|
jest.mock('../src/features/talk/llm-stream-service', () => ({
|
||||||
|
streamTalkResponse: (...args: unknown[]) => mockStreamTalkResponse(...args),
|
||||||
|
}))
|
||||||
|
jest.mock('../src/features/talk/talk-transcription-service', () => ({
|
||||||
|
transcribeTalkRecording: jest.fn(),
|
||||||
|
}))
|
||||||
|
jest.mock('../src/features/talk/talk-tts', () => {
|
||||||
|
class MockTalkTtsError extends Error {
|
||||||
|
code = 'FAILED'
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
shutdownTalkSpeech: jest.fn(async () => undefined),
|
||||||
|
speakTalkText: jest.fn(async () => ({ status: 'completed' })),
|
||||||
|
stopTalkSpeech: jest.fn(async () => true),
|
||||||
|
TalkTtsError: MockTalkTtsError,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
jest.mock('../src/features/talk/talk-debug-audio', () => ({
|
||||||
|
isDeterministicTalkAudioAvailable: () => false,
|
||||||
|
synthesizeDeterministicTalkAudio: jest.fn(),
|
||||||
|
}))
|
||||||
|
jest.mock('../src/lib/audio-recorder', () => {
|
||||||
|
class MockRecorderBusyError extends Error {
|
||||||
|
code = 'recorder_busy'
|
||||||
|
holder: string
|
||||||
|
|
||||||
|
constructor(mockHolder: string) {
|
||||||
|
super(`The recorder is in use by ${mockHolder}`)
|
||||||
|
this.holder = mockHolder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
RecorderBusyError: MockRecorderBusyError,
|
||||||
|
audioRecorder: {
|
||||||
|
acquire: (...args: unknown[]) => mockAcquire(...args),
|
||||||
|
requestPermission: () => mockRequestPermission(),
|
||||||
|
// The pre-lease API: a global start/cancel any screen could call.
|
||||||
|
start: () => mockLegacyStart(),
|
||||||
|
cancel: () => mockGlobalCancel(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import TalkScreen from '../src/screens/TalkScreen'
|
||||||
|
import { RecorderBusyError } from '../src/lib/audio-recorder'
|
||||||
|
|
||||||
|
async function flush(): Promise<void> {
|
||||||
|
for (let index = 0; index < 5; index += 1) {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function byTestId(root: ReactTestInstance, testID: string, prop: string): ReactTestInstance {
|
||||||
|
const match = root.findAll((node) => (
|
||||||
|
node.props.testID === testID && typeof node.props[prop] === 'function'
|
||||||
|
))[0]
|
||||||
|
if (match === undefined) throw new Error(`Missing ${testID} with ${prop}`)
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
function textContent(root: ReactTestInstance): string {
|
||||||
|
return root.findAll((node) => typeof node.props.children === 'string')
|
||||||
|
.map((node) => node.props.children as string)
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
let renderer: ReactTestRenderer | null = null
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockSession = { user: { id: USER_ID }, access_token: 'token-1' }
|
||||||
|
mockAcquire.mockReset()
|
||||||
|
mockGlobalCancel.mockClear()
|
||||||
|
mockLegacyStart.mockClear()
|
||||||
|
mockStreamTalkResponse.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => renderer?.unmount())
|
||||||
|
renderer = null
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TalkScreen redteam r1-24', () => {
|
||||||
|
test('pressing the mic while a meeting recording holds the recorder never cancels it', async () => {
|
||||||
|
mockAcquire.mockRejectedValue(new RecorderBusyError('record'))
|
||||||
|
await act(async () => {
|
||||||
|
renderer = create(<TalkScreen />)
|
||||||
|
})
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
byTestId(renderer!.root, 'talk-ptt', 'onPressIn').props.onPressIn()
|
||||||
|
})
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
expect(mockAcquire).toHaveBeenCalledWith('talk')
|
||||||
|
expect(mockGlobalCancel).not.toHaveBeenCalled()
|
||||||
|
expect(textContent(renderer!.root)).toContain('mobile.talk.error.recording')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an access-token refresh for the same user keeps the in-flight reply', async () => {
|
||||||
|
let resolveStream: (value: { text: string, generationId: string }) => void = () => undefined
|
||||||
|
let streamSignal: AbortSignal | null = null
|
||||||
|
mockStreamTalkResponse.mockImplementation((_messages: unknown, options: { signal: AbortSignal }) => {
|
||||||
|
streamSignal = options.signal
|
||||||
|
return new Promise((resolve) => { resolveStream = resolve })
|
||||||
|
})
|
||||||
|
await act(async () => {
|
||||||
|
renderer = create(<TalkScreen />)
|
||||||
|
})
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
byTestId(renderer!.root, 'talk-input', 'onChangeText').props.onChangeText('hello')
|
||||||
|
})
|
||||||
|
await act(async () => {
|
||||||
|
byTestId(renderer!.root, 'talk-send', 'onPress').props.onPress()
|
||||||
|
})
|
||||||
|
await flush()
|
||||||
|
expect(mockStreamTalkResponse).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// supabase-js TOKEN_REFRESHED: same user, rotated access token.
|
||||||
|
mockSession = { user: { id: USER_ID }, access_token: 'token-2' }
|
||||||
|
await act(async () => {
|
||||||
|
renderer!.update(<TalkScreen />)
|
||||||
|
})
|
||||||
|
expect(streamSignal!.aborted).toBe(false)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveStream({ text: 'the finished reply', generationId: 'gen-1' })
|
||||||
|
})
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
const text = textContent(renderer!.root)
|
||||||
|
expect(text).toContain('the finished reply')
|
||||||
|
expect(text).not.toContain('mobile.talk.error.auth')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('switching to a different user still cancels the in-flight reply', async () => {
|
||||||
|
let streamSignal: AbortSignal | null = null
|
||||||
|
mockStreamTalkResponse.mockImplementation((_messages: unknown, options: { signal: AbortSignal }) => {
|
||||||
|
streamSignal = options.signal
|
||||||
|
return new Promise(() => undefined)
|
||||||
|
})
|
||||||
|
await act(async () => {
|
||||||
|
renderer = create(<TalkScreen />)
|
||||||
|
})
|
||||||
|
await act(async () => {
|
||||||
|
byTestId(renderer!.root, 'talk-input', 'onChangeText').props.onChangeText('hello')
|
||||||
|
})
|
||||||
|
await act(async () => {
|
||||||
|
byTestId(renderer!.root, 'talk-send', 'onPress').props.onPress()
|
||||||
|
})
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
mockSession = { user: { id: '33333333-3333-4333-8333-333333333333' }, access_token: 'token-3' }
|
||||||
|
await act(async () => {
|
||||||
|
renderer!.update(<TalkScreen />)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(streamSignal!.aborted).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
20
apps/mobile-rn/src/features/talk/talk-account.ts
Normal file
20
apps/mobile-rn/src/features/talk/talk-account.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -1,676 +1,148 @@
|
||||||
import { NativeModules, PermissionsAndroid, Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
import { Dirs, FileSystem } from 'react-native-file-access';
|
import { AndroidForegroundRecorderBackend } from './recorder/android-foreground-backend';
|
||||||
import {
|
import { NitroRecorderBackend } from './recorder/nitro-backend';
|
||||||
AudioEncoderAndroidType,
|
import { RecorderBusyError } from './recorder/recorder-errors';
|
||||||
AudioSourceAndroidType,
|
import type {
|
||||||
AVEncoderAudioQualityIOSType,
|
RecordedAudio,
|
||||||
OutputFormatAndroidType,
|
RecorderBackend,
|
||||||
createSound,
|
RecorderOwner,
|
||||||
type AudioSet,
|
RecorderSession,
|
||||||
type RecordBackType,
|
RecordingProgressHandler,
|
||||||
} from 'react-native-nitro-sound';
|
RecordingStateHandler,
|
||||||
import { createUuidV4 } from './random-id';
|
StartRecordingOptions,
|
||||||
|
} from './recorder/recorder-types';
|
||||||
|
|
||||||
const RECORDING_PREFIX = 'd3ro-recording-';
|
export type {
|
||||||
const RECORDING_EXTENSION = '.m4a';
|
RecordedAudio,
|
||||||
const RECORDING_MIME_TYPE = 'audio/mp4';
|
RecorderBackend,
|
||||||
const ANDROID_RECORDING_MIME_TYPE = 'audio/wav';
|
RecorderOwner,
|
||||||
|
RecorderSession,
|
||||||
|
RecordingProgress,
|
||||||
|
RecordingRuntimeSnapshot,
|
||||||
|
RecordingRuntimeState,
|
||||||
|
StartRecordingOptions,
|
||||||
|
} from './recorder/recorder-types';
|
||||||
|
export { RecorderBusyError } from './recorder/recorder-errors';
|
||||||
|
|
||||||
const AUDIO_SETTINGS: AudioSet = {
|
/**
|
||||||
AudioSourceAndroid: AudioSourceAndroidType.MIC,
|
* A capture found with no lease (the Android foreground service survives a JS
|
||||||
OutputFormatAndroid: OutputFormatAndroidType.MPEG_4,
|
* restart) can only have come from a meeting recording.
|
||||||
AudioEncoderAndroid: AudioEncoderAndroidType.AAC,
|
*/
|
||||||
AudioQuality: 'medium',
|
const UNLEASED_CAPTURE_OWNER: RecorderOwner = 'record';
|
||||||
AudioChannels: 1,
|
|
||||||
AudioSamplingRate: 44100,
|
|
||||||
AudioEncodingBitRate: 128000,
|
|
||||||
AVFormatIDKeyIOS: 'aac',
|
|
||||||
AVEncoderAudioQualityKeyIOS: AVEncoderAudioQualityIOSType.high,
|
|
||||||
AVNumberOfChannelsKeyIOS: 1,
|
|
||||||
AVSampleRateKeyIOS: 44100,
|
|
||||||
};
|
|
||||||
|
|
||||||
type NativeRecorderState =
|
export function createPlatformRecorderBackend(): RecorderBackend {
|
||||||
| 'idle'
|
return Platform.OS === 'android'
|
||||||
| 'starting'
|
? new AndroidForegroundRecorderBackend()
|
||||||
| 'recording'
|
: new NitroRecorderBackend();
|
||||||
| '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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
export class AudioRecorder {
|
||||||
private state: NativeRecorderState = 'idle';
|
private selectedBackend: RecorderBackend | null;
|
||||||
private currentPath: string | null = null;
|
private lease: RecorderOwner | 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 getSound(): ReturnType<typeof createSound> {
|
constructor(backend?: RecorderBackend) {
|
||||||
if (this.sound !== null) return this.sound;
|
this.selectedBackend = backend ?? null;
|
||||||
|
|
||||||
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> {
|
private get backend(): RecorderBackend {
|
||||||
if (Platform.OS !== 'android') {
|
if (this.selectedBackend === null) {
|
||||||
// Nitro Sound requests iOS microphone access through AVAudioSession.
|
this.selectedBackend = createPlatformRecorderBackend();
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
return this.selectedBackend;
|
||||||
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(
|
requestPermission(): Promise<boolean> {
|
||||||
onProgress: (progress: RecordingProgress) => void,
|
return this.backend.requestPermission();
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async pause(): Promise<void> {
|
/**
|
||||||
if (this.state !== 'recording') {
|
* Returns a session for `owner`, or throws {@link RecorderBusyError} without
|
||||||
throw new Error(`Cannot pause recorder while it is ${this.state}`);
|
* touching the capture when another owner holds it.
|
||||||
}
|
*/
|
||||||
|
async acquire(owner: RecorderOwner): Promise<RecorderSession> {
|
||||||
this.state = 'pausing';
|
await this.assertAvailable(owner);
|
||||||
if (Platform.OS === 'android') {
|
return this.createSession(owner);
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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> {
|
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 {
|
try {
|
||||||
if (this.state === 'recording' || this.state === 'paused') {
|
await this.backend.cancel();
|
||||||
await this.getSound().stopRecorder();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
stopError = error;
|
|
||||||
} finally {
|
} finally {
|
||||||
this.progressHandler = null;
|
this.lease = null;
|
||||||
this.currentPath = null;
|
|
||||||
this.state = 'idle';
|
|
||||||
this.progress = { durationMs: 0, meteringDb: null };
|
|
||||||
await deleteOwnedTemporaryFile(outputPath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stopError !== null) throw stopError;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async restore(
|
private createSession(owner: RecorderOwner): RecorderSession {
|
||||||
onProgress?: (progress: RecordingProgress) => void,
|
return {
|
||||||
onStateChange?: (snapshot: RecordingRuntimeSnapshot) => void,
|
owner,
|
||||||
): Promise<RecordingRuntimeSnapshot> {
|
start: (onProgress: RecordingProgressHandler, options?: StartRecordingOptions) =>
|
||||||
if (Platform.OS !== 'android') {
|
this.runAsOwner(owner, true, () => this.backend.start(onProgress, options)),
|
||||||
return {
|
pause: () => this.runAsOwner(owner, false, () => this.backend.pause()),
|
||||||
state: 'idle',
|
resume: () => this.runAsOwner(owner, false, () => this.backend.resume()),
|
||||||
recording: null,
|
stop: () => this.runAsOwner(owner, false, () => this.backend.stop()),
|
||||||
meetingId: null,
|
cleanup: (recording?: Pick<RecordedAudio, 'path'>) => this.cleanupAsOwner(owner, recording),
|
||||||
interruptionReason: null,
|
cancel: () => this.cancelAsOwner(owner),
|
||||||
startedAtMs: 0,
|
restore: (onProgress?: RecordingProgressHandler, onStateChange?: RecordingStateHandler) =>
|
||||||
};
|
this.runAsOwner(owner, true, () => this.backend.restore(onProgress, onStateChange)),
|
||||||
}
|
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
if (snapshot.state === 'recording' || snapshot.state === 'paused') {
|
}
|
||||||
this.state = snapshot.state;
|
|
||||||
this.startAndroidPolling();
|
private async holder(): Promise<RecorderOwner | null> {
|
||||||
} else if (snapshot.state === 'stopped' || snapshot.state === 'recoverable') {
|
if (!(await this.backend.isBusy())) return null;
|
||||||
this.state = 'stopped';
|
return this.lease ?? UNLEASED_CAPTURE_OWNER;
|
||||||
} else {
|
}
|
||||||
this.state = 'idle';
|
|
||||||
|
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 {
|
private async runAsOwner<T>(
|
||||||
const generation = ++this.androidPollGeneration;
|
owner: RecorderOwner,
|
||||||
const poll = async (): Promise<void> => {
|
claim: boolean,
|
||||||
if (generation !== this.androidPollGeneration) return;
|
operation: () => Promise<T>,
|
||||||
try {
|
): Promise<T> {
|
||||||
const native = await androidRecordingModule().getStatus();
|
await this.assertAvailable(owner);
|
||||||
if (generation !== this.androidPollGeneration) return;
|
if (claim) this.lease = owner;
|
||||||
const snapshot = parseNativeSnapshot(native);
|
return operation();
|
||||||
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 stopAndroidPolling(): void {
|
private async cleanupAsOwner(
|
||||||
this.androidPollGeneration += 1;
|
owner: RecorderOwner,
|
||||||
|
recording?: Pick<RecordedAudio, 'path'>,
|
||||||
|
): Promise<void> {
|
||||||
|
// An explicit recording is path-scoped and cannot touch another capture;
|
||||||
|
// an implicit cleanup targets the current capture and needs ownership.
|
||||||
|
if (recording === undefined) await this.assertAvailable(owner);
|
||||||
|
await this.backend.cleanup(recording);
|
||||||
|
this.releaseIfIdle(owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cancelAsOwner(owner: RecorderOwner): Promise<void> {
|
||||||
|
if ((await this.holder()) !== owner) return;
|
||||||
|
if (this.lease !== null && this.lease !== owner) return;
|
||||||
|
try {
|
||||||
|
await this.backend.cancel();
|
||||||
|
} finally {
|
||||||
|
this.releaseIfIdle(owner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private releaseIfIdle(owner: RecorderOwner): void {
|
||||||
|
if (this.lease === owner && !this.backend.hasInProcessCapture()) this.lease = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
407
apps/mobile-rn/src/lib/recorder/android-foreground-backend.ts
Normal file
407
apps/mobile-rn/src/lib/recorder/android-foreground-backend.ts
Normal file
|
|
@ -0,0 +1,407 @@
|
||||||
|
import { NativeModules, PermissionsAndroid } from 'react-native';
|
||||||
|
import type {
|
||||||
|
RecordedAudio,
|
||||||
|
RecorderBackend,
|
||||||
|
RecordingProgress,
|
||||||
|
RecordingProgressHandler,
|
||||||
|
RecordingRuntimeSnapshot,
|
||||||
|
RecordingRuntimeState,
|
||||||
|
RecordingStateHandler,
|
||||||
|
StartRecordingOptions,
|
||||||
|
} from './recorder-types';
|
||||||
|
|
||||||
|
const ANDROID_RECORDING_MIME_TYPE = 'audio/wav';
|
||||||
|
/** A WAV with nothing but its RIFF header holds no audio. */
|
||||||
|
const WAV_HEADER_BYTES = 44;
|
||||||
|
|
||||||
|
type AndroidRecorderState =
|
||||||
|
| 'idle'
|
||||||
|
| 'starting'
|
||||||
|
| 'recording'
|
||||||
|
| 'pausing'
|
||||||
|
| 'paused'
|
||||||
|
| 'resuming'
|
||||||
|
| 'stopping'
|
||||||
|
| 'stopped';
|
||||||
|
|
||||||
|
interface NativeRecordingSnapshot {
|
||||||
|
state: RecordingRuntimeState;
|
||||||
|
path: string | null;
|
||||||
|
uri: string | null;
|
||||||
|
fileName: string | null;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
durationMs: number;
|
||||||
|
meteringDb: number | null;
|
||||||
|
meetingId: string | null;
|
||||||
|
interruptionReason: string | null;
|
||||||
|
startedAtMs: number;
|
||||||
|
recoverable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface D3RORecordingNativeModule {
|
||||||
|
getStatus: () => Promise<NativeRecordingSnapshot>;
|
||||||
|
start: (options: { meetingId?: string }) => Promise<NativeRecordingSnapshot>;
|
||||||
|
pause: () => Promise<NativeRecordingSnapshot>;
|
||||||
|
resume: () => Promise<NativeRecordingSnapshot>;
|
||||||
|
stop: () => Promise<NativeRecordingSnapshot>;
|
||||||
|
cancel: () => Promise<NativeRecordingSnapshot>;
|
||||||
|
dispose: (path: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function androidRecordingModule(): D3RORecordingNativeModule {
|
||||||
|
const candidate = NativeModules.D3RORecording as
|
||||||
|
| Partial<D3RORecordingNativeModule>
|
||||||
|
| undefined;
|
||||||
|
if (
|
||||||
|
candidate === undefined ||
|
||||||
|
typeof candidate.getStatus !== 'function' ||
|
||||||
|
typeof candidate.start !== 'function' ||
|
||||||
|
typeof candidate.pause !== 'function' ||
|
||||||
|
typeof candidate.resume !== 'function' ||
|
||||||
|
typeof candidate.stop !== 'function' ||
|
||||||
|
typeof candidate.cancel !== 'function' ||
|
||||||
|
typeof candidate.dispose !== 'function'
|
||||||
|
) {
|
||||||
|
throw new Error('Android foreground recording module is unavailable');
|
||||||
|
}
|
||||||
|
return candidate as D3RORecordingNativeModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFiniteNonNegative(value: unknown, field: string): number {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||||
|
throw new Error(`Android recorder returned invalid ${field}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseNativeSnapshot(value: NativeRecordingSnapshot): RecordingRuntimeSnapshot {
|
||||||
|
const states: readonly RecordingRuntimeState[] = [
|
||||||
|
'idle',
|
||||||
|
'starting',
|
||||||
|
'recording',
|
||||||
|
'paused',
|
||||||
|
'stopped',
|
||||||
|
'recoverable',
|
||||||
|
];
|
||||||
|
if (!states.includes(value.state)) {
|
||||||
|
throw new Error('Android recorder returned an invalid state');
|
||||||
|
}
|
||||||
|
const durationMs = assertFiniteNonNegative(value.durationMs, 'duration');
|
||||||
|
const size = assertFiniteNonNegative(value.size, 'file size');
|
||||||
|
const startedAtMs = assertFiniteNonNegative(value.startedAtMs, 'start time');
|
||||||
|
const hasFile = value.path !== null || value.uri !== null || value.fileName !== null;
|
||||||
|
if (
|
||||||
|
hasFile &&
|
||||||
|
(typeof value.path !== 'string' ||
|
||||||
|
value.path.length === 0 ||
|
||||||
|
value.uri !== `file://${value.path}` ||
|
||||||
|
typeof value.fileName !== 'string' ||
|
||||||
|
!value.fileName.endsWith('.wav') ||
|
||||||
|
value.mimeType !== ANDROID_RECORDING_MIME_TYPE)
|
||||||
|
) {
|
||||||
|
throw new Error('Android recorder returned an invalid recording file');
|
||||||
|
}
|
||||||
|
if (value.state !== 'idle' && !hasFile) {
|
||||||
|
throw new Error('Android recorder state has no recording file');
|
||||||
|
}
|
||||||
|
const recording = hasFile
|
||||||
|
? {
|
||||||
|
uri: value.uri as string,
|
||||||
|
path: value.path as string,
|
||||||
|
fileName: value.fileName as string,
|
||||||
|
mimeType: ANDROID_RECORDING_MIME_TYPE,
|
||||||
|
size,
|
||||||
|
durationMs,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
state: value.state,
|
||||||
|
recording,
|
||||||
|
meetingId: typeof value.meetingId === 'string' ? value.meetingId : null,
|
||||||
|
interruptionReason:
|
||||||
|
typeof value.interruptionReason === 'string'
|
||||||
|
? value.interruptionReason
|
||||||
|
: null,
|
||||||
|
startedAtMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android capture through the D3RORecording foreground service. The native
|
||||||
|
* side owns the WAV file (under filesDir) and its journal, so this backend
|
||||||
|
* never deletes files itself: it only asks the module to cancel or dispose.
|
||||||
|
*/
|
||||||
|
export class AndroidForegroundRecorderBackend implements RecorderBackend {
|
||||||
|
private state: AndroidRecorderState = 'idle';
|
||||||
|
private currentPath: string | null = null;
|
||||||
|
private progress: RecordingProgress = { durationMs: 0, meteringDb: null };
|
||||||
|
private progressHandler: RecordingProgressHandler | null = null;
|
||||||
|
private stateHandler: RecordingStateHandler | null = null;
|
||||||
|
private pollGeneration = 0;
|
||||||
|
|
||||||
|
async requestPermission(): Promise<boolean> {
|
||||||
|
const permission = PermissionsAndroid.PERMISSIONS.RECORD_AUDIO;
|
||||||
|
if (await PermissionsAndroid.check(permission)) return true;
|
||||||
|
|
||||||
|
const result = await PermissionsAndroid.request(permission, {
|
||||||
|
title: 'Microphone access',
|
||||||
|
message:
|
||||||
|
'D3RO Voice needs microphone access to record and transcribe audio.',
|
||||||
|
buttonPositive: 'Allow',
|
||||||
|
buttonNegative: 'Cancel',
|
||||||
|
});
|
||||||
|
|
||||||
|
return result === PermissionsAndroid.RESULTS.GRANTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(
|
||||||
|
onProgress: RecordingProgressHandler,
|
||||||
|
options: StartRecordingOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
|
if (this.state !== 'idle') {
|
||||||
|
throw new Error(`Cannot start recorder while it is ${this.state}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = 'starting';
|
||||||
|
this.progressHandler = onProgress;
|
||||||
|
this.stateHandler = options.onStateChange ?? null;
|
||||||
|
try {
|
||||||
|
const snapshot = parseNativeSnapshot(
|
||||||
|
await androidRecordingModule().start(
|
||||||
|
options.meetingId === undefined
|
||||||
|
? {}
|
||||||
|
: { meetingId: options.meetingId },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (snapshot.state !== 'recording' || snapshot.recording === null) {
|
||||||
|
throw new Error('Android foreground recorder did not start');
|
||||||
|
}
|
||||||
|
this.currentPath = snapshot.recording.path;
|
||||||
|
this.progress = {
|
||||||
|
durationMs: snapshot.recording.durationMs,
|
||||||
|
meteringDb: null,
|
||||||
|
};
|
||||||
|
this.state = 'recording';
|
||||||
|
this.stateHandler?.(snapshot);
|
||||||
|
this.startPolling();
|
||||||
|
} catch (error) {
|
||||||
|
this.progressHandler = null;
|
||||||
|
this.stateHandler = null;
|
||||||
|
this.currentPath = null;
|
||||||
|
this.state = 'idle';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async pause(): Promise<void> {
|
||||||
|
if (this.state !== 'recording') {
|
||||||
|
throw new Error(`Cannot pause recorder while it is ${this.state}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = 'pausing';
|
||||||
|
try {
|
||||||
|
const snapshot = parseNativeSnapshot(await androidRecordingModule().pause());
|
||||||
|
if (snapshot.state !== 'paused') throw new Error('Android recorder did not pause');
|
||||||
|
this.state = 'paused';
|
||||||
|
this.stateHandler?.(snapshot);
|
||||||
|
} catch (error) {
|
||||||
|
this.state = 'recording';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resume(): Promise<void> {
|
||||||
|
if (this.state !== 'paused') {
|
||||||
|
throw new Error(`Cannot resume recorder while it is ${this.state}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = 'resuming';
|
||||||
|
try {
|
||||||
|
const snapshot = parseNativeSnapshot(await androidRecordingModule().resume());
|
||||||
|
if (snapshot.state !== 'recording') throw new Error('Android recorder did not resume');
|
||||||
|
this.state = 'recording';
|
||||||
|
this.stateHandler?.(snapshot);
|
||||||
|
} catch (error) {
|
||||||
|
this.state = 'paused';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<RecordedAudio> {
|
||||||
|
if (this.state !== 'recording' && this.state !== 'paused') {
|
||||||
|
throw new Error(`Cannot stop recorder while it is ${this.state}`);
|
||||||
|
}
|
||||||
|
if (this.currentPath === null) throw new Error('Recorder has no output path');
|
||||||
|
|
||||||
|
const previousState = this.state;
|
||||||
|
this.state = 'stopping';
|
||||||
|
let snapshot: RecordingRuntimeSnapshot;
|
||||||
|
try {
|
||||||
|
snapshot = parseNativeSnapshot(await androidRecordingModule().stop());
|
||||||
|
} catch (error) {
|
||||||
|
await this.resyncAfterFailedStop(previousState, null);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recording = snapshot.recording;
|
||||||
|
if (
|
||||||
|
(snapshot.state !== 'stopped' && snapshot.state !== 'recoverable') ||
|
||||||
|
recording === null ||
|
||||||
|
recording.size <= WAV_HEADER_BYTES
|
||||||
|
) {
|
||||||
|
const error = new Error('Android recorder produced no recoverable audio');
|
||||||
|
await this.resyncAfterFailedStop(previousState, snapshot);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stopPolling();
|
||||||
|
this.currentPath = recording.path;
|
||||||
|
this.progress = { durationMs: recording.durationMs, meteringDb: null };
|
||||||
|
this.state = 'stopped';
|
||||||
|
const stateHandler = this.stateHandler;
|
||||||
|
this.progressHandler = null;
|
||||||
|
this.stateHandler = null;
|
||||||
|
stateHandler?.(snapshot);
|
||||||
|
return recording;
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanup(recording?: Pick<RecordedAudio, 'path'>): Promise<void> {
|
||||||
|
const path = recording?.path ?? this.currentPath;
|
||||||
|
if (path !== null) await androidRecordingModule().dispose(path);
|
||||||
|
// Disposing an older file must not reset a capture that is running now.
|
||||||
|
if (recording !== undefined && this.currentPath !== null && path !== this.currentPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.stopPolling();
|
||||||
|
this.resetToIdle();
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(): Promise<void> {
|
||||||
|
this.stopPolling();
|
||||||
|
await androidRecordingModule().cancel();
|
||||||
|
this.resetToIdle();
|
||||||
|
}
|
||||||
|
|
||||||
|
async restore(
|
||||||
|
onProgress?: RecordingProgressHandler,
|
||||||
|
onStateChange?: RecordingStateHandler,
|
||||||
|
): Promise<RecordingRuntimeSnapshot> {
|
||||||
|
const snapshot = parseNativeSnapshot(await androidRecordingModule().getStatus());
|
||||||
|
this.stopPolling();
|
||||||
|
this.progressHandler = onProgress ?? null;
|
||||||
|
this.stateHandler = onStateChange ?? null;
|
||||||
|
this.adoptSnapshot(snapshot);
|
||||||
|
this.progressHandler?.(this.progress);
|
||||||
|
this.stateHandler?.(snapshot);
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async isBusy(): Promise<boolean> {
|
||||||
|
if (this.state !== 'idle') return true;
|
||||||
|
// The foreground service outlives the JS process, so idle JS state alone
|
||||||
|
// does not prove the microphone and its journal are free.
|
||||||
|
const snapshot = parseNativeSnapshot(await androidRecordingModule().getStatus());
|
||||||
|
return snapshot.state !== 'idle';
|
||||||
|
}
|
||||||
|
|
||||||
|
hasInProcessCapture(): boolean {
|
||||||
|
return this.state !== 'idle';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A failed stop must leave the recording where the native side says it is:
|
||||||
|
* still capturing, or stopped/recoverable with its WAV intact. The owner is
|
||||||
|
* told through its state handler so it can offer process/discard actions.
|
||||||
|
*/
|
||||||
|
private async resyncAfterFailedStop(
|
||||||
|
previousState: 'recording' | 'paused',
|
||||||
|
known: RecordingRuntimeSnapshot | null,
|
||||||
|
): Promise<void> {
|
||||||
|
this.stopPolling();
|
||||||
|
let snapshot = known;
|
||||||
|
if (snapshot === null) {
|
||||||
|
try {
|
||||||
|
snapshot = parseNativeSnapshot(await androidRecordingModule().getStatus());
|
||||||
|
} catch {
|
||||||
|
snapshot = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (snapshot === null) {
|
||||||
|
// Native state is unreadable right now; keep the last known capture and
|
||||||
|
// let polling report whatever the service settles on.
|
||||||
|
this.state = previousState;
|
||||||
|
this.startPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.adoptSnapshot(snapshot);
|
||||||
|
this.stateHandler?.(snapshot);
|
||||||
|
if (this.state !== 'recording' && this.state !== 'paused') {
|
||||||
|
this.progressHandler = null;
|
||||||
|
this.stateHandler = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private adoptSnapshot(snapshot: RecordingRuntimeSnapshot): void {
|
||||||
|
this.currentPath = snapshot.recording?.path ?? null;
|
||||||
|
this.progress = {
|
||||||
|
durationMs: snapshot.recording?.durationMs ?? 0,
|
||||||
|
meteringDb: null,
|
||||||
|
};
|
||||||
|
if (snapshot.state === 'recording' || snapshot.state === 'paused') {
|
||||||
|
this.state = snapshot.state;
|
||||||
|
this.startPolling();
|
||||||
|
} else if (snapshot.state === 'stopped' || snapshot.state === 'recoverable') {
|
||||||
|
this.state = 'stopped';
|
||||||
|
} else {
|
||||||
|
this.state = 'idle';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetToIdle(): void {
|
||||||
|
this.progressHandler = null;
|
||||||
|
this.stateHandler = null;
|
||||||
|
this.currentPath = null;
|
||||||
|
this.state = 'idle';
|
||||||
|
this.progress = { durationMs: 0, meteringDb: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
private startPolling(): void {
|
||||||
|
const generation = ++this.pollGeneration;
|
||||||
|
const poll = async (): Promise<void> => {
|
||||||
|
if (generation !== this.pollGeneration) return;
|
||||||
|
try {
|
||||||
|
const native = await androidRecordingModule().getStatus();
|
||||||
|
if (generation !== this.pollGeneration) return;
|
||||||
|
const snapshot = parseNativeSnapshot(native);
|
||||||
|
const nextProgress = {
|
||||||
|
durationMs: snapshot.recording?.durationMs ?? 0,
|
||||||
|
meteringDb:
|
||||||
|
typeof native.meteringDb === 'number' && Number.isFinite(native.meteringDb)
|
||||||
|
? native.meteringDb
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
this.progress = nextProgress;
|
||||||
|
this.progressHandler?.(nextProgress);
|
||||||
|
this.stateHandler?.(snapshot);
|
||||||
|
if (snapshot.state === 'recording' || snapshot.state === 'paused') {
|
||||||
|
this.state = snapshot.state;
|
||||||
|
setTimeout(() => { void poll(); }, 250);
|
||||||
|
} else {
|
||||||
|
this.currentPath = snapshot.recording?.path ?? null;
|
||||||
|
this.state = snapshot.state === 'idle' ? 'idle' : 'stopped';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A transient React bridge failure must not stop the native foreground capture.
|
||||||
|
if (generation === this.pollGeneration) {
|
||||||
|
setTimeout(() => { void poll(); }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopPolling(): void {
|
||||||
|
this.pollGeneration += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
373
apps/mobile-rn/src/lib/recorder/nitro-backend.ts
Normal file
373
apps/mobile-rn/src/lib/recorder/nitro-backend.ts
Normal file
|
|
@ -0,0 +1,373 @@
|
||||||
|
import { Dirs, FileSystem } from 'react-native-file-access';
|
||||||
|
import {
|
||||||
|
AudioEncoderAndroidType,
|
||||||
|
AudioSourceAndroidType,
|
||||||
|
AVEncoderAudioQualityIOSType,
|
||||||
|
OutputFormatAndroidType,
|
||||||
|
createSound,
|
||||||
|
type AudioSet,
|
||||||
|
type RecordBackType,
|
||||||
|
} from 'react-native-nitro-sound';
|
||||||
|
import { createUuidV4 } from '../random-id';
|
||||||
|
import type {
|
||||||
|
RecordedAudio,
|
||||||
|
RecorderBackend,
|
||||||
|
RecordingProgress,
|
||||||
|
RecordingProgressHandler,
|
||||||
|
RecordingRuntimeSnapshot,
|
||||||
|
} from './recorder-types';
|
||||||
|
|
||||||
|
const RECORDING_PREFIX = 'd3ro-recording-';
|
||||||
|
const RECORDING_EXTENSION = '.m4a';
|
||||||
|
const RECORDING_MIME_TYPE = 'audio/mp4';
|
||||||
|
|
||||||
|
const AUDIO_SETTINGS: AudioSet = {
|
||||||
|
AudioSourceAndroid: AudioSourceAndroidType.MIC,
|
||||||
|
OutputFormatAndroid: OutputFormatAndroidType.MPEG_4,
|
||||||
|
AudioEncoderAndroid: AudioEncoderAndroidType.AAC,
|
||||||
|
AudioQuality: 'medium',
|
||||||
|
AudioChannels: 1,
|
||||||
|
AudioSamplingRate: 44100,
|
||||||
|
AudioEncodingBitRate: 128000,
|
||||||
|
AVFormatIDKeyIOS: 'aac',
|
||||||
|
AVEncoderAudioQualityKeyIOS: AVEncoderAudioQualityIOSType.high,
|
||||||
|
AVNumberOfChannelsKeyIOS: 1,
|
||||||
|
AVSampleRateKeyIOS: 44100,
|
||||||
|
};
|
||||||
|
|
||||||
|
type NitroRecorderState =
|
||||||
|
| 'idle'
|
||||||
|
| 'starting'
|
||||||
|
| 'recording'
|
||||||
|
| 'pausing'
|
||||||
|
| 'paused'
|
||||||
|
| 'resuming'
|
||||||
|
| 'stopping'
|
||||||
|
| 'stopped';
|
||||||
|
|
||||||
|
function createTemporaryPath(): string {
|
||||||
|
return `${Dirs.CacheDir}/${RECORDING_PREFIX}${createUuidV4()}${RECORDING_EXTENSION}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathFromFileUri(uri: string): string | null {
|
||||||
|
if (!uri.startsWith('file://')) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(uri.slice('file://'.length));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOwnedTemporaryPath(path: string): boolean {
|
||||||
|
const cachePrefix = `${Dirs.CacheDir}/`;
|
||||||
|
if (!path.startsWith(cachePrefix)) return false;
|
||||||
|
|
||||||
|
const fileName = path.slice(cachePrefix.length);
|
||||||
|
return (
|
||||||
|
!fileName.includes('/') &&
|
||||||
|
!fileName.includes('\\') &&
|
||||||
|
fileName.startsWith(RECORDING_PREFIX) &&
|
||||||
|
fileName.endsWith(RECORDING_EXTENSION)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteOwnedTemporaryFile(path: string | null): Promise<void> {
|
||||||
|
if (path === null) return;
|
||||||
|
if (!isOwnedTemporaryPath(path)) {
|
||||||
|
throw new Error('Refusing to delete a recording outside the app cache');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await FileSystem.exists(path)) {
|
||||||
|
await FileSystem.unlink(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteStaleTemporaryFiles(): Promise<void> {
|
||||||
|
const entries = await FileSystem.ls(Dirs.CacheDir);
|
||||||
|
const stalePaths = entries
|
||||||
|
.filter(
|
||||||
|
entry =>
|
||||||
|
entry.startsWith(RECORDING_PREFIX) &&
|
||||||
|
entry.endsWith(RECORDING_EXTENSION),
|
||||||
|
)
|
||||||
|
.map(entry => `${Dirs.CacheDir}/${entry}`);
|
||||||
|
|
||||||
|
await Promise.all(stalePaths.map(deleteOwnedTemporaryFile));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-process capture through Nitro Sound (iOS). This backend creates its
|
||||||
|
* .m4a files in the app cache and is the only code allowed to delete them.
|
||||||
|
*/
|
||||||
|
export class NitroRecorderBackend implements RecorderBackend {
|
||||||
|
private state: NitroRecorderState = 'idle';
|
||||||
|
private currentPath: string | null = null;
|
||||||
|
private progress: RecordingProgress = { durationMs: 0, meteringDb: null };
|
||||||
|
private pendingStart: Promise<void> | null = null;
|
||||||
|
private pendingTransition: Promise<void> | null = null;
|
||||||
|
private pendingStop: Promise<RecordedAudio> | null = null;
|
||||||
|
private sound: ReturnType<typeof createSound> | null = null;
|
||||||
|
private progressHandler: RecordingProgressHandler | null = null;
|
||||||
|
|
||||||
|
private getSound(): ReturnType<typeof createSound> {
|
||||||
|
if (this.sound !== null) return this.sound;
|
||||||
|
|
||||||
|
const sound = createSound();
|
||||||
|
sound.setSubscriptionDuration(0.1);
|
||||||
|
sound.addRecordBackListener((event: RecordBackType) => {
|
||||||
|
if (this.progressHandler === null) return;
|
||||||
|
|
||||||
|
const durationMs = Number.isFinite(event.currentPosition)
|
||||||
|
? Math.max(0, Math.round(event.currentPosition))
|
||||||
|
: this.progress.durationMs;
|
||||||
|
const meteringDb =
|
||||||
|
typeof event.currentMetering === 'number' &&
|
||||||
|
Number.isFinite(event.currentMetering)
|
||||||
|
? event.currentMetering
|
||||||
|
: null;
|
||||||
|
|
||||||
|
this.progress = { durationMs, meteringDb };
|
||||||
|
this.progressHandler(this.progress);
|
||||||
|
});
|
||||||
|
this.sound = sound;
|
||||||
|
return sound;
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestPermission(): Promise<boolean> {
|
||||||
|
// Nitro Sound requests iOS microphone access through AVAudioSession.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(onProgress: RecordingProgressHandler): Promise<void> {
|
||||||
|
if (this.state !== 'idle') {
|
||||||
|
throw new Error(`Cannot start recorder while it is ${this.state}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = 'starting';
|
||||||
|
try {
|
||||||
|
await deleteStaleTemporaryFiles();
|
||||||
|
} catch (error) {
|
||||||
|
this.state = 'idle';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputPath = createTemporaryPath();
|
||||||
|
this.currentPath = outputPath;
|
||||||
|
this.progress = { durationMs: 0, meteringDb: null };
|
||||||
|
this.progressHandler = onProgress;
|
||||||
|
const sound = this.getSound();
|
||||||
|
|
||||||
|
const pendingStart = (async () => {
|
||||||
|
try {
|
||||||
|
const startedUri = await sound.startRecorder(
|
||||||
|
outputPath,
|
||||||
|
AUDIO_SETTINGS,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const startedPath = pathFromFileUri(startedUri);
|
||||||
|
|
||||||
|
if (startedPath !== outputPath) {
|
||||||
|
throw new Error('Recorder returned an unexpected output URI');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = 'recording';
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await sound.stopRecorder();
|
||||||
|
} catch {
|
||||||
|
// stopRecorder also releases the native recorder when stopping fails.
|
||||||
|
}
|
||||||
|
this.progressHandler = null;
|
||||||
|
if (this.currentPath === outputPath) this.currentPath = null;
|
||||||
|
this.state = 'idle';
|
||||||
|
await deleteOwnedTemporaryFile(outputPath);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
this.pendingStart = pendingStart;
|
||||||
|
try {
|
||||||
|
await pendingStart;
|
||||||
|
} finally {
|
||||||
|
if (this.pendingStart === pendingStart) this.pendingStart = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async pause(): Promise<void> {
|
||||||
|
if (this.state !== 'recording') {
|
||||||
|
throw new Error(`Cannot pause recorder while it is ${this.state}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = 'pausing';
|
||||||
|
await this.runTransition(async () => {
|
||||||
|
try {
|
||||||
|
await this.getSound().pauseRecorder();
|
||||||
|
this.state = 'paused';
|
||||||
|
} catch (error) {
|
||||||
|
this.state = 'recording';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async resume(): Promise<void> {
|
||||||
|
if (this.state !== 'paused') {
|
||||||
|
throw new Error(`Cannot resume recorder while it is ${this.state}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = 'resuming';
|
||||||
|
await this.runTransition(async () => {
|
||||||
|
try {
|
||||||
|
await this.getSound().resumeRecorder();
|
||||||
|
this.state = 'recording';
|
||||||
|
} catch (error) {
|
||||||
|
this.state = 'paused';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<RecordedAudio> {
|
||||||
|
if (this.state !== 'recording' && this.state !== 'paused') {
|
||||||
|
throw new Error(`Cannot stop recorder while it is ${this.state}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputPath = this.currentPath;
|
||||||
|
if (outputPath === null) throw new Error('Recorder has no output path');
|
||||||
|
|
||||||
|
const pendingStop = (async (): Promise<RecordedAudio> => {
|
||||||
|
this.state = 'stopping';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stoppedUri = await this.getSound().stopRecorder();
|
||||||
|
const stoppedPath = pathFromFileUri(stoppedUri);
|
||||||
|
|
||||||
|
if (stoppedPath !== outputPath) {
|
||||||
|
throw new Error('Recorder did not return the expected output file');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = await FileSystem.stat(outputPath);
|
||||||
|
if (stat.type !== 'file' || stat.size <= 0) {
|
||||||
|
throw new Error('Recorder produced an empty audio file');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = 'stopped';
|
||||||
|
return {
|
||||||
|
uri: stoppedUri,
|
||||||
|
path: outputPath,
|
||||||
|
fileName: outputPath.slice(outputPath.lastIndexOf('/') + 1),
|
||||||
|
mimeType: RECORDING_MIME_TYPE,
|
||||||
|
size: stat.size,
|
||||||
|
durationMs: this.progress.durationMs,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.currentPath = null;
|
||||||
|
this.state = 'idle';
|
||||||
|
try {
|
||||||
|
await deleteOwnedTemporaryFile(outputPath);
|
||||||
|
} catch {
|
||||||
|
// The stop failure is what the caller must see; a stale cache file
|
||||||
|
// is swept by deleteStaleTemporaryFiles() on the next start.
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.progressHandler = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
this.pendingStop = pendingStop;
|
||||||
|
try {
|
||||||
|
return await pendingStop;
|
||||||
|
} finally {
|
||||||
|
if (this.pendingStop === pendingStop) this.pendingStop = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanup(recording?: Pick<RecordedAudio, 'path'>): Promise<void> {
|
||||||
|
const path = recording?.path ?? this.currentPath;
|
||||||
|
await deleteOwnedTemporaryFile(path);
|
||||||
|
|
||||||
|
if (path === this.currentPath) {
|
||||||
|
this.currentPath = null;
|
||||||
|
this.state = 'idle';
|
||||||
|
this.progress = { durationMs: 0, meteringDb: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(): Promise<void> {
|
||||||
|
if (this.state === 'starting' && this.pendingStart !== null) {
|
||||||
|
try {
|
||||||
|
await this.pendingStart;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state === 'stopping' && this.pendingStop !== null) {
|
||||||
|
try {
|
||||||
|
await this.pendingStop;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.pendingTransition !== null) {
|
||||||
|
try {
|
||||||
|
await this.pendingTransition;
|
||||||
|
} catch {
|
||||||
|
// Continue cleanup from the stable pre-transition recording state.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputPath = this.currentPath;
|
||||||
|
let stopError: unknown = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.state === 'recording' || this.state === 'paused') {
|
||||||
|
await this.getSound().stopRecorder();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
stopError = error;
|
||||||
|
} finally {
|
||||||
|
this.progressHandler = null;
|
||||||
|
this.currentPath = null;
|
||||||
|
this.state = 'idle';
|
||||||
|
this.progress = { durationMs: 0, meteringDb: null };
|
||||||
|
await deleteOwnedTemporaryFile(outputPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stopError !== null) throw stopError;
|
||||||
|
}
|
||||||
|
|
||||||
|
async restore(): Promise<RecordingRuntimeSnapshot> {
|
||||||
|
// Nitro captures die with the JS process; there is nothing to reattach.
|
||||||
|
return {
|
||||||
|
state: 'idle',
|
||||||
|
recording: null,
|
||||||
|
meetingId: null,
|
||||||
|
interruptionReason: null,
|
||||||
|
startedAtMs: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async isBusy(): Promise<boolean> {
|
||||||
|
return this.state !== 'idle';
|
||||||
|
}
|
||||||
|
|
||||||
|
hasInProcessCapture(): boolean {
|
||||||
|
return this.state !== 'idle';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runTransition(transition: () => Promise<void>): Promise<void> {
|
||||||
|
const pendingTransition = transition();
|
||||||
|
this.pendingTransition = pendingTransition;
|
||||||
|
try {
|
||||||
|
await pendingTransition;
|
||||||
|
} finally {
|
||||||
|
if (this.pendingTransition === pendingTransition) {
|
||||||
|
this.pendingTransition = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/mobile-rn/src/lib/recorder/recorder-errors.ts
Normal file
14
apps/mobile-rn/src/lib/recorder/recorder-errors.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { RecorderOwner } from './recorder-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown before any side effect when another owner holds the shared capture.
|
||||||
|
* Callers must not try to clean up after it: they never owned anything.
|
||||||
|
*/
|
||||||
|
export class RecorderBusyError extends Error {
|
||||||
|
readonly code = 'recorder_busy';
|
||||||
|
|
||||||
|
constructor(readonly holder: RecorderOwner) {
|
||||||
|
super(`The recorder is in use by ${holder}`);
|
||||||
|
this.name = 'RecorderBusyError';
|
||||||
|
}
|
||||||
|
}
|
||||||
87
apps/mobile-rn/src/lib/recorder/recorder-types.ts
Normal file
87
apps/mobile-rn/src/lib/recorder/recorder-types.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
export interface RecordingProgress {
|
||||||
|
durationMs: number;
|
||||||
|
meteringDb: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordedAudio {
|
||||||
|
uri: string;
|
||||||
|
path: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RecordingRuntimeState =
|
||||||
|
| 'idle'
|
||||||
|
| 'starting'
|
||||||
|
| 'recording'
|
||||||
|
| 'paused'
|
||||||
|
| 'stopped'
|
||||||
|
| 'recoverable';
|
||||||
|
|
||||||
|
export interface RecordingRuntimeSnapshot {
|
||||||
|
state: RecordingRuntimeState;
|
||||||
|
recording: RecordedAudio | null;
|
||||||
|
meetingId: string | null;
|
||||||
|
interruptionReason: string | null;
|
||||||
|
startedAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RecordingProgressHandler = (progress: RecordingProgress) => void;
|
||||||
|
export type RecordingStateHandler = (snapshot: RecordingRuntimeSnapshot) => void;
|
||||||
|
|
||||||
|
export interface StartRecordingOptions {
|
||||||
|
meetingId?: string;
|
||||||
|
onStateChange?: RecordingStateHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Screens that may hold the shared microphone capture. */
|
||||||
|
export type RecorderOwner = 'record' | 'talk';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform capture port. Each implementation owns its native API, its state
|
||||||
|
* machine and its file-ownership/cleanup policy; nothing is shared between
|
||||||
|
* platforms, so one platform's cleanup rule cannot leak into another's.
|
||||||
|
*/
|
||||||
|
export interface RecorderBackend {
|
||||||
|
requestPermission(): Promise<boolean>;
|
||||||
|
start(onProgress: RecordingProgressHandler, options?: StartRecordingOptions): Promise<void>;
|
||||||
|
pause(): Promise<void>;
|
||||||
|
resume(): Promise<void>;
|
||||||
|
stop(): Promise<RecordedAudio>;
|
||||||
|
/** Removes one finished recording. With no argument it targets the current capture. */
|
||||||
|
cleanup(recording?: Pick<RecordedAudio, 'path'>): Promise<void>;
|
||||||
|
/** Discards the current capture and its file, whoever started it. */
|
||||||
|
cancel(): Promise<void>;
|
||||||
|
restore(
|
||||||
|
onProgress?: RecordingProgressHandler,
|
||||||
|
onStateChange?: RecordingStateHandler,
|
||||||
|
): Promise<RecordingRuntimeSnapshot>;
|
||||||
|
/**
|
||||||
|
* True while any capture exists, including one that outlived the JS process
|
||||||
|
* or a stopped/recoverable file that has not been disposed yet.
|
||||||
|
*/
|
||||||
|
isBusy(): Promise<boolean>;
|
||||||
|
/** Synchronous view of the capture this JS process is driving. */
|
||||||
|
hasInProcessCapture(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owner-scoped handle on the shared recorder. Every mutating call verifies
|
||||||
|
* that no other owner holds the capture; cancel() is a no-op for a capture the
|
||||||
|
* owner does not hold, so one screen can never discard another screen's audio.
|
||||||
|
*/
|
||||||
|
export interface RecorderSession {
|
||||||
|
readonly owner: RecorderOwner;
|
||||||
|
start(onProgress: RecordingProgressHandler, options?: StartRecordingOptions): Promise<void>;
|
||||||
|
pause(): Promise<void>;
|
||||||
|
resume(): Promise<void>;
|
||||||
|
stop(): Promise<RecordedAudio>;
|
||||||
|
cleanup(recording?: Pick<RecordedAudio, 'path'>): Promise<void>;
|
||||||
|
cancel(): Promise<void>;
|
||||||
|
restore(
|
||||||
|
onProgress?: RecordingProgressHandler,
|
||||||
|
onStateChange?: RecordingStateHandler,
|
||||||
|
): Promise<RecordingRuntimeSnapshot>;
|
||||||
|
}
|
||||||
|
|
@ -22,7 +22,9 @@ import { useI18n } from '@d3ro/i18n';
|
||||||
import { useAuth } from '../lib/auth-context';
|
import { useAuth } from '../lib/auth-context';
|
||||||
import {
|
import {
|
||||||
audioRecorder,
|
audioRecorder,
|
||||||
|
RecorderBusyError,
|
||||||
type RecordedAudio,
|
type RecordedAudio,
|
||||||
|
type RecorderSession,
|
||||||
type RecordingProgress,
|
type RecordingProgress,
|
||||||
type RecordingRuntimeSnapshot,
|
type RecordingRuntimeSnapshot,
|
||||||
} from '../lib/audio-recorder';
|
} from '../lib/audio-recorder';
|
||||||
|
|
@ -67,6 +69,8 @@ type RecordingState =
|
||||||
| 'error';
|
| 'error';
|
||||||
|
|
||||||
function errorMessage(error: unknown, fallback: string): string {
|
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() !== ''
|
return error instanceof Error && error.message.trim() !== ''
|
||||||
? error.message
|
? error.message
|
||||||
: fallback;
|
: fallback;
|
||||||
|
|
@ -142,12 +146,32 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
||||||
const queueItemIdRef = useRef<string | null>(null);
|
const queueItemIdRef = useRef<string | null>(null);
|
||||||
const recoveredRecordingRef = useRef<RecordedAudio | null>(null);
|
const recoveredRecordingRef = useRef<RecordedAudio | null>(null);
|
||||||
const activeMeetingIdRef = useRef<string | null>(route.params?.meetingId ?? 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 routeMeetingId = route.params?.meetingId ?? null;
|
||||||
const routeMeetingTitle = route.params?.meetingTitle ?? null;
|
const routeMeetingTitle = route.params?.meetingTitle ?? null;
|
||||||
const routeIncomingMediaId = route.params?.incomingMediaId ?? null;
|
const routeIncomingMediaId = route.params?.incomingMediaId ?? null;
|
||||||
const handledIncomingMediaIds = useRef(new Set<string>());
|
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 {
|
function applyRecorderSnapshot(snapshot: RecordingRuntimeSnapshot): void {
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
activeMeetingIdRef.current = snapshot.meetingId ?? activeMeetingIdRef.current;
|
activeMeetingIdRef.current = snapshot.meetingId ?? activeMeetingIdRef.current;
|
||||||
|
|
@ -178,24 +202,20 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true;
|
mountedRef.current = true;
|
||||||
void recorder.restore(
|
void recordSession()
|
||||||
(progress: RecordingProgress) => {
|
.then(session => session.restore(handleRecorderProgress, applyRecorderSnapshot))
|
||||||
if (!mountedRef.current) return;
|
.catch(restoreError => {
|
||||||
setDurationMs(progress.durationMs);
|
// A Talk capture is not Record's to restore; leave Record idle.
|
||||||
setMeteringDb(progress.meteringDb);
|
if (!mountedRef.current || restoreError instanceof RecorderBusyError) return;
|
||||||
},
|
setError(errorMessage(restoreError, t('mobile.rec.recoveryFailed')));
|
||||||
applyRecorderSnapshot,
|
setState('error');
|
||||||
).catch(restoreError => {
|
});
|
||||||
if (!mountedRef.current) return;
|
|
||||||
setError(errorMessage(restoreError, t('mobile.rec.recoveryFailed')));
|
|
||||||
setState('error');
|
|
||||||
});
|
|
||||||
return () => {
|
return () => {
|
||||||
mountedRef.current = false;
|
mountedRef.current = false;
|
||||||
};
|
};
|
||||||
// Foreground recording intentionally survives tab/screen unmount. Logout cleanup is global.
|
// Foreground recording intentionally survives tab/screen unmount. Logout cleanup is global.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [recorder]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => subscribeToDurableQueue(event => {
|
useEffect(() => subscribeToDurableQueue(event => {
|
||||||
if (event.type === 'completed') {
|
if (event.type === 'completed') {
|
||||||
|
|
@ -320,7 +340,11 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
||||||
recorderActiveRef.current = true;
|
recorderActiveRef.current = true;
|
||||||
let meetingBegun = false;
|
let meetingBegun = false;
|
||||||
try {
|
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 (!mountedRef.current) return;
|
||||||
if (!granted) {
|
if (!granted) {
|
||||||
recorderActiveRef.current = false;
|
recorderActiveRef.current = false;
|
||||||
|
|
@ -336,11 +360,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
||||||
} else {
|
} else {
|
||||||
activeMeetingIdRef.current = null;
|
activeMeetingIdRef.current = null;
|
||||||
}
|
}
|
||||||
await recorder.start((progress: RecordingProgress) => {
|
await recorder.start(handleRecorderProgress, {
|
||||||
if (!mountedRef.current) return;
|
|
||||||
setDurationMs(progress.durationMs);
|
|
||||||
setMeteringDb(progress.meteringDb);
|
|
||||||
}, {
|
|
||||||
meetingId: activeMeetingIdRef.current ?? undefined,
|
meetingId: activeMeetingIdRef.current ?? undefined,
|
||||||
onStateChange: applyRecorderSnapshot,
|
onStateChange: applyRecorderSnapshot,
|
||||||
});
|
});
|
||||||
|
|
@ -367,6 +387,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
||||||
if (!beginAction()) return;
|
if (!beginAction()) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
const recorder = await recordSession();
|
||||||
if (state === 'recording') {
|
if (state === 'recording') {
|
||||||
await recorder.pause();
|
await recorder.pause();
|
||||||
if (mountedRef.current) {
|
if (mountedRef.current) {
|
||||||
|
|
@ -391,7 +412,14 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
||||||
setError(null);
|
setError(null);
|
||||||
setState('stopping');
|
setState('stopping');
|
||||||
try {
|
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;
|
recorderActiveRef.current = false;
|
||||||
recoveredRecordingRef.current = null;
|
recoveredRecordingRef.current = null;
|
||||||
if (!mountedRef.current) {
|
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> {
|
async function processRecoveredRecording(): Promise<void> {
|
||||||
const recording = recoveredRecordingRef.current;
|
const recording = recoveredRecordingRef.current;
|
||||||
if (recording === null || !beginAction()) return;
|
if (recording === null || !beginAction()) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
setState('validating');
|
setState('validating');
|
||||||
try {
|
try {
|
||||||
|
const recorder = await recordSession();
|
||||||
const input = await prepareRecordedAudio(recording, async () => {
|
const input = await prepareRecordedAudio(recording, async () => {
|
||||||
await recorder.cleanup(recording);
|
await recorder.cleanup(recording);
|
||||||
});
|
});
|
||||||
|
|
@ -444,6 +496,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
||||||
const recording = recoveredRecordingRef.current;
|
const recording = recoveredRecordingRef.current;
|
||||||
if (recording === null || !beginAction()) return;
|
if (recording === null || !beginAction()) return;
|
||||||
try {
|
try {
|
||||||
|
const recorder = await recordSession();
|
||||||
await recorder.cleanup(recording);
|
await recorder.cleanup(recording);
|
||||||
recoveredRecordingRef.current = null;
|
recoveredRecordingRef.current = null;
|
||||||
const meetingId = activeMeetingIdRef.current;
|
const meetingId = activeMeetingIdRef.current;
|
||||||
|
|
@ -584,6 +637,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
||||||
}
|
}
|
||||||
if (!recorderActiveRef.current || !beginAction()) return;
|
if (!recorderActiveRef.current || !beginAction()) return;
|
||||||
try {
|
try {
|
||||||
|
const recorder = await recordSession();
|
||||||
await recorder.cancel();
|
await recorder.cancel();
|
||||||
recorderActiveRef.current = false;
|
recorderActiveRef.current = false;
|
||||||
const meetingId = activeMeetingIdRef.current;
|
const meetingId = activeMeetingIdRef.current;
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,12 @@ import { useI18n } from '@d3ro/i18n'
|
||||||
import { ThemeButton, ThemeText } from '../theme/themed-components'
|
import { ThemeButton, ThemeText } from '../theme/themed-components'
|
||||||
import { useAuth } from '../lib/auth-context'
|
import { useAuth } from '../lib/auth-context'
|
||||||
import { useMobilePreferences } from '../lib/preferences-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 {
|
import {
|
||||||
ChatServiceError,
|
ChatServiceError,
|
||||||
type ChatErrorCode,
|
type ChatErrorCode,
|
||||||
|
|
@ -25,6 +30,7 @@ import {
|
||||||
} from '../features/chat/chat-service'
|
} from '../features/chat/chat-service'
|
||||||
import { AudioPipelineError } from '../features/import/audio-import-types'
|
import { AudioPipelineError } from '../features/import/audio-import-types'
|
||||||
import { streamTalkResponse } from '../features/talk/llm-stream-service'
|
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 { transcribeTalkRecording } from '../features/talk/talk-transcription-service'
|
||||||
import {
|
import {
|
||||||
shutdownTalkSpeech,
|
shutdownTalkSpeech,
|
||||||
|
|
@ -128,6 +134,9 @@ export default function TalkScreen(): React.ReactElement {
|
||||||
const recordingStartingRef = useRef(false)
|
const recordingStartingRef = useRef(false)
|
||||||
const recordingFinishingRef = useRef(false)
|
const recordingFinishingRef = useRef(false)
|
||||||
const recorderCleanupRef = useRef<Promise<void>>(Promise.resolve())
|
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 pressHeldRef = useRef(false)
|
||||||
const textSendPendingRef = useRef(false)
|
const textSendPendingRef = useRef(false)
|
||||||
const retryMessagesRef = useRef<TalkMessage[] | null>(null)
|
const retryMessagesRef = useRef<TalkMessage[] | null>(null)
|
||||||
|
|
@ -197,8 +206,12 @@ export default function TalkScreen(): React.ReactElement {
|
||||||
if (assistantId !== null) {
|
if (assistantId !== null) {
|
||||||
updateMessages((current) => current.filter((message) => message.id !== assistantId))
|
updateMessages((current) => current.filter((message) => message.id !== assistantId))
|
||||||
}
|
}
|
||||||
if (recordingStartingRef.current || recordingActiveRef.current || recordingFinishingRef.current) {
|
const recorderSession = recorderSessionRef.current
|
||||||
const cleanup = audioRecorder.cancel().catch(() => undefined)
|
if (
|
||||||
|
recorderSession !== null
|
||||||
|
&& (recordingStartingRef.current || recordingActiveRef.current || recordingFinishingRef.current)
|
||||||
|
) {
|
||||||
|
const cleanup = recorderSession.cancel().catch(() => undefined)
|
||||||
recorderCleanupRef.current = cleanup
|
recorderCleanupRef.current = cleanup
|
||||||
}
|
}
|
||||||
recordingStartingRef.current = false
|
recordingStartingRef.current = false
|
||||||
|
|
@ -234,7 +247,7 @@ export default function TalkScreen(): React.ReactElement {
|
||||||
() => cancelActiveOperation(false)
|
() => cancelActiveOperation(false)
|
||||||
), [cancelActiveOperation]))
|
), [cancelActiveOperation]))
|
||||||
|
|
||||||
const authIdentity = session === null ? 'signed-out' : `${session.user.id}:${session.access_token}`
|
const authIdentity = talkAccountKey(session)
|
||||||
const previousAuthIdentityRef = useRef(authIdentity)
|
const previousAuthIdentityRef = useRef(authIdentity)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (previousAuthIdentityRef.current !== authIdentity) {
|
if (previousAuthIdentityRef.current !== authIdentity) {
|
||||||
|
|
@ -328,12 +341,7 @@ export default function TalkScreen(): React.ReactElement {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if (!isCurrentOperation(operationId)) return
|
if (!isCurrentOperation(operationId)) return
|
||||||
const latestSession = sessionRef.current
|
if (!isSameTalkAccount(currentSession, sessionRef.current)) {
|
||||||
if (
|
|
||||||
latestSession === null
|
|
||||||
|| latestSession.user.id !== currentSession.user.id
|
|
||||||
|| latestSession.access_token !== currentSession.access_token
|
|
||||||
) {
|
|
||||||
throw new ChatServiceError('AUTH_REQUIRED', false, 401)
|
throw new ChatServiceError('AUTH_REQUIRED', false, 401)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -395,18 +403,31 @@ export default function TalkScreen(): React.ReactElement {
|
||||||
}, [replaceMessages, runConversation])
|
}, [replaceMessages, runConversation])
|
||||||
|
|
||||||
const finishRecording = useCallback(async (operationId: number): Promise<void> => {
|
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
|
recordingFinishingRef.current = true
|
||||||
recordingActiveRef.current = false
|
recordingActiveRef.current = false
|
||||||
|
|
||||||
try {
|
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)) {
|
if (!isCurrentOperation(operationId)) {
|
||||||
await audioRecorder.cleanup(recording)
|
await recorderSession.cleanup(recording)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (recording.durationMs < MIN_RECORDING_MS) {
|
if (recording.durationMs < MIN_RECORDING_MS) {
|
||||||
await audioRecorder.cleanup(recording)
|
await recorderSession.cleanup(recording)
|
||||||
setErrorCode('RECORDING_TOO_SHORT')
|
setErrorCode('RECORDING_TOO_SHORT')
|
||||||
updatePhase('idle')
|
updatePhase('idle')
|
||||||
return
|
return
|
||||||
|
|
@ -417,7 +438,7 @@ export default function TalkScreen(): React.ReactElement {
|
||||||
updatePhase('transcribing')
|
updatePhase('transcribing')
|
||||||
const currentSession = sessionRef.current
|
const currentSession = sessionRef.current
|
||||||
if (currentSession === null) {
|
if (currentSession === null) {
|
||||||
await audioRecorder.cleanup(recording)
|
await recorderSession.cleanup(recording)
|
||||||
throw new AudioPipelineError('auth', 'Sign in to transcribe speech')
|
throw new AudioPipelineError('auth', 'Sign in to transcribe speech')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -425,16 +446,11 @@ export default function TalkScreen(): React.ReactElement {
|
||||||
accessToken: currentSession.access_token,
|
accessToken: currentSession.access_token,
|
||||||
languageCode: preferences.locale,
|
languageCode: preferences.locale,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
disposeRecording: () => audioRecorder.cleanup(recording),
|
disposeRecording: () => recorderSession.cleanup(recording),
|
||||||
})
|
})
|
||||||
transcriptionControllerRef.current = null
|
transcriptionControllerRef.current = null
|
||||||
if (!isCurrentOperation(operationId)) return
|
if (!isCurrentOperation(operationId)) return
|
||||||
const latestSession = sessionRef.current
|
if (!isSameTalkAccount(currentSession, sessionRef.current)) {
|
||||||
if (
|
|
||||||
latestSession === null
|
|
||||||
|| latestSession.user.id !== currentSession.user.id
|
|
||||||
|| latestSession.access_token !== currentSession.access_token
|
|
||||||
) {
|
|
||||||
throw new AudioPipelineError('auth', 'The account changed during transcription')
|
throw new AudioPipelineError('auth', 'The account changed during transcription')
|
||||||
}
|
}
|
||||||
setInput('')
|
setInput('')
|
||||||
|
|
@ -476,6 +492,7 @@ export default function TalkScreen(): React.ReactElement {
|
||||||
retryMessagesRef.current = null
|
retryMessagesRef.current = null
|
||||||
await stopSpeech()
|
await stopSpeech()
|
||||||
|
|
||||||
|
let recorderSession: RecorderSession | null = null
|
||||||
try {
|
try {
|
||||||
await recorderCleanupRef.current
|
await recorderCleanupRef.current
|
||||||
if (!isCurrentOperation(operationId)) return
|
if (!isCurrentOperation(operationId)) return
|
||||||
|
|
@ -484,6 +501,11 @@ export default function TalkScreen(): React.ReactElement {
|
||||||
setErrorCode('AUTH_REQUIRED')
|
setErrorCode('AUTH_REQUIRED')
|
||||||
return
|
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()
|
const granted = await audioRecorder.requestPermission()
|
||||||
if (!isCurrentOperation(operationId)) return
|
if (!isCurrentOperation(operationId)) return
|
||||||
if (!granted) {
|
if (!granted) {
|
||||||
|
|
@ -491,11 +513,11 @@ export default function TalkScreen(): React.ReactElement {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await audioRecorder.start((progress) => {
|
await recorderSession.start((progress) => {
|
||||||
if (isCurrentOperation(operationId)) setRecordingProgress(progress)
|
if (isCurrentOperation(operationId)) setRecordingProgress(progress)
|
||||||
})
|
})
|
||||||
if (!isCurrentOperation(operationId)) {
|
if (!isCurrentOperation(operationId)) {
|
||||||
await audioRecorder.cancel().catch(() => undefined)
|
await recorderSession.cancel().catch(() => undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordingActiveRef.current = true
|
recordingActiveRef.current = true
|
||||||
|
|
@ -506,7 +528,9 @@ export default function TalkScreen(): React.ReactElement {
|
||||||
setErrorCode('RECORDING_FAILED')
|
setErrorCode('RECORDING_FAILED')
|
||||||
updatePhase('idle')
|
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 {
|
} finally {
|
||||||
recordingStartingRef.current = false
|
recordingStartingRef.current = false
|
||||||
}
|
}
|
||||||
|
|
@ -575,12 +599,7 @@ export default function TalkScreen(): React.ReactElement {
|
||||||
fixture = null
|
fixture = null
|
||||||
transcriptionControllerRef.current = null
|
transcriptionControllerRef.current = null
|
||||||
if (!isCurrentOperation(operationId)) return
|
if (!isCurrentOperation(operationId)) return
|
||||||
const latestSession = sessionRef.current
|
if (!isSameTalkAccount(currentSession, sessionRef.current)) {
|
||||||
if (
|
|
||||||
latestSession === null ||
|
|
||||||
latestSession.user.id !== currentSession.user.id ||
|
|
||||||
latestSession.access_token !== currentSession.access_token
|
|
||||||
) {
|
|
||||||
throw new AudioPipelineError('auth', 'The account changed during transcription')
|
throw new AudioPipelineError('auth', 'The account changed during transcription')
|
||||||
}
|
}
|
||||||
submitMessage(result.text, true)
|
submitMessage(result.text, true)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue