mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-20 11:36:25 +08:00
feat(native): record encoding (#14188)
fix #13784 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Start/stop system or meeting recordings with Ogg/Opus artifacts and native start/stop APIs; workspace backup recovery. * **Refactor** * Simplified recording lifecycle and UI flows; native runtime now orchestrates recording/processing and reporting. * **Bug Fixes** * Stronger path validation, safer import/export dialogs, consistent error handling/logging, and retry-safe recording processing. * **Chores** * Added cross-platform native audio capture and Ogg/Opus encoding support. * **Tests** * New unit, integration, and e2e tests for recording, path guards, dialogs, and workspace recovery. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -13,6 +13,19 @@ import type { FrameworkProvider } from '@toeverything/infra';
|
||||
import { getCurrentWorkspace, isAiEnabled } from './utils';
|
||||
|
||||
const logger = new DebugLogger('electron-renderer:recording');
|
||||
const RECORDING_PROCESS_RETRY_MS = 1000;
|
||||
const NATIVE_RECORDING_MIME_TYPE = 'audio/ogg';
|
||||
|
||||
type ProcessingRecordingStatus = {
|
||||
id: number;
|
||||
status: 'processing';
|
||||
appName?: string;
|
||||
blockCreationStatus?: undefined;
|
||||
filepath: string;
|
||||
startTime: number;
|
||||
};
|
||||
|
||||
type WorkspaceHandle = NonNullable<ReturnType<typeof getCurrentWorkspace>>;
|
||||
|
||||
async function readRecordingFile(filepath: string) {
|
||||
if (apis?.recording?.readRecordingFile) {
|
||||
@@ -45,118 +58,217 @@ async function saveRecordingBlob(blobEngine: BlobEngine, filepath: string) {
|
||||
logger.debug('Saving recording', filepath);
|
||||
const opusBuffer = await readRecordingFile(filepath);
|
||||
const blob = new Blob([opusBuffer], {
|
||||
type: 'audio/mp4',
|
||||
type: NATIVE_RECORDING_MIME_TYPE,
|
||||
});
|
||||
const blobId = await blobEngine.set(blob);
|
||||
logger.debug('Recording saved', blobId);
|
||||
return { blob, blobId };
|
||||
}
|
||||
|
||||
export function setupRecordingEvents(frameworkProvider: FrameworkProvider) {
|
||||
events?.recording.onRecordingStatusChanged(status => {
|
||||
(async () => {
|
||||
if ((await apis?.ui.isActiveTab()) && status?.status === 'ready') {
|
||||
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
|
||||
if (!currentWorkspace) {
|
||||
// maybe the workspace is not ready yet, eg. for shared workspace view
|
||||
await apis?.recording.handleBlockCreationFailed(status.id);
|
||||
return;
|
||||
}
|
||||
const { workspace } = currentWorkspace;
|
||||
const docsService = workspace.scope.get(DocsService);
|
||||
const aiEnabled = isAiEnabled(frameworkProvider);
|
||||
function shouldProcessRecording(
|
||||
status: unknown
|
||||
): status is ProcessingRecordingStatus {
|
||||
return (
|
||||
!!status &&
|
||||
typeof status === 'object' &&
|
||||
'status' in status &&
|
||||
status.status === 'processing' &&
|
||||
'filepath' in status &&
|
||||
typeof status.filepath === 'string' &&
|
||||
!('blockCreationStatus' in status && status.blockCreationStatus)
|
||||
);
|
||||
}
|
||||
|
||||
const timestamp = i18nTime(status.startTime, {
|
||||
absolute: {
|
||||
accuracy: 'minute',
|
||||
noYear: true,
|
||||
},
|
||||
});
|
||||
async function createRecordingDoc(
|
||||
frameworkProvider: FrameworkProvider,
|
||||
workspace: WorkspaceHandle['workspace'],
|
||||
status: ProcessingRecordingStatus
|
||||
) {
|
||||
const docsService = workspace.scope.get(DocsService);
|
||||
const aiEnabled = isAiEnabled(frameworkProvider);
|
||||
const recordingFilepath = status.filepath;
|
||||
|
||||
const docProps: DocProps = {
|
||||
onStoreLoad: (doc, { noteId }) => {
|
||||
(async () => {
|
||||
if (status.filepath) {
|
||||
// it takes a while to save the blob, so we show the attachment first
|
||||
const { blobId, blob } = await saveRecordingBlob(
|
||||
doc.workspace.blobSync,
|
||||
status.filepath
|
||||
);
|
||||
const timestamp = i18nTime(status.startTime, {
|
||||
absolute: {
|
||||
accuracy: 'minute',
|
||||
noYear: true,
|
||||
},
|
||||
});
|
||||
|
||||
// name + timestamp(readable) + extension
|
||||
const attachmentName =
|
||||
(status.appName ?? 'System Audio') +
|
||||
' ' +
|
||||
timestamp +
|
||||
'.opus';
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const docProps: DocProps = {
|
||||
onStoreLoad: (doc, { noteId }) => {
|
||||
void (async () => {
|
||||
// it takes a while to save the blob, so we show the attachment first
|
||||
const { blobId, blob } = await saveRecordingBlob(
|
||||
doc.workspace.blobSync,
|
||||
recordingFilepath
|
||||
);
|
||||
|
||||
// add size and sourceId to the attachment later
|
||||
const attachmentId = doc.addBlock(
|
||||
'affine:attachment',
|
||||
{
|
||||
name: attachmentName,
|
||||
type: 'audio/opus',
|
||||
size: blob.size,
|
||||
sourceId: blobId,
|
||||
embed: true,
|
||||
},
|
||||
noteId
|
||||
);
|
||||
// name + timestamp(readable) + extension
|
||||
const attachmentName =
|
||||
(status.appName ?? 'System Audio') + ' ' + timestamp + '.opus';
|
||||
|
||||
const model = doc.getBlock(attachmentId)
|
||||
?.model as AttachmentBlockModel;
|
||||
const attachmentId = doc.addBlock(
|
||||
'affine:attachment',
|
||||
{
|
||||
name: attachmentName,
|
||||
type: NATIVE_RECORDING_MIME_TYPE,
|
||||
size: blob.size,
|
||||
sourceId: blobId,
|
||||
embed: true,
|
||||
},
|
||||
noteId
|
||||
);
|
||||
|
||||
if (!aiEnabled) {
|
||||
return;
|
||||
}
|
||||
const model = doc.getBlock(attachmentId)
|
||||
?.model as AttachmentBlockModel;
|
||||
|
||||
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
|
||||
if (!currentWorkspace) {
|
||||
return;
|
||||
}
|
||||
const { workspace } = currentWorkspace;
|
||||
using audioAttachment = workspace.scope
|
||||
.get(AudioAttachmentService)
|
||||
.get(model);
|
||||
audioAttachment?.obj
|
||||
.transcribe()
|
||||
.then(() => {
|
||||
track.doc.editor.audioBlock.transcribeRecording({
|
||||
type: 'Meeting record',
|
||||
method: 'success',
|
||||
option: 'Auto transcribing',
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error('Failed to transcribe recording', err);
|
||||
});
|
||||
} else {
|
||||
throw new Error('No attachment model found');
|
||||
}
|
||||
})()
|
||||
.then(async () => {
|
||||
await apis?.recording.handleBlockCreationSuccess(status.id);
|
||||
})
|
||||
.catch(error => {
|
||||
logger.error('Failed to transcribe recording', error);
|
||||
return apis?.recording.handleBlockCreationFailed(
|
||||
status.id,
|
||||
error
|
||||
);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('unknown error', error);
|
||||
if (!aiEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
|
||||
if (!currentWorkspace) {
|
||||
return;
|
||||
}
|
||||
const { workspace } = currentWorkspace;
|
||||
using audioAttachment = workspace.scope
|
||||
.get(AudioAttachmentService)
|
||||
.get(model);
|
||||
audioAttachment?.obj
|
||||
.transcribe()
|
||||
.then(() => {
|
||||
track.doc.editor.audioBlock.transcribeRecording({
|
||||
type: 'Meeting record',
|
||||
method: 'success',
|
||||
option: 'Auto transcribing',
|
||||
});
|
||||
},
|
||||
};
|
||||
const page = docsService.createDoc({
|
||||
docProps,
|
||||
title:
|
||||
'Recording ' + (status.appName ?? 'System Audio') + ' ' + timestamp,
|
||||
primaryMode: 'page',
|
||||
});
|
||||
workspace.scope.get(WorkbenchService).workbench.openDoc(page.id);
|
||||
}
|
||||
})().catch(console.error);
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error('Failed to transcribe recording', err);
|
||||
});
|
||||
})().then(resolve, reject);
|
||||
},
|
||||
};
|
||||
|
||||
const page = docsService.createDoc({
|
||||
docProps,
|
||||
title:
|
||||
'Recording ' + (status.appName ?? 'System Audio') + ' ' + timestamp,
|
||||
primaryMode: 'page',
|
||||
});
|
||||
workspace.scope.get(WorkbenchService).workbench.openDoc(page.id);
|
||||
});
|
||||
}
|
||||
|
||||
export function setupRecordingEvents(frameworkProvider: FrameworkProvider) {
|
||||
let pendingStatus: ProcessingRecordingStatus | null = null;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let processingStatusId: number | null = null;
|
||||
|
||||
const clearRetry = () => {
|
||||
if (retryTimer !== null) {
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearPending = (id?: number) => {
|
||||
if (id === undefined || pendingStatus?.id === id) {
|
||||
pendingStatus = null;
|
||||
clearRetry();
|
||||
}
|
||||
if (id === undefined || processingStatusId === id) {
|
||||
processingStatusId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleRetry = () => {
|
||||
if (!pendingStatus || retryTimer !== null) {
|
||||
return;
|
||||
}
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
void processPendingStatus().catch(console.error);
|
||||
}, RECORDING_PROCESS_RETRY_MS);
|
||||
};
|
||||
|
||||
const processPendingStatus = async () => {
|
||||
const status = pendingStatus;
|
||||
if (!status || processingStatusId === status.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isActiveTab = false;
|
||||
try {
|
||||
isActiveTab = !!(await apis?.ui.isActiveTab());
|
||||
} catch (error) {
|
||||
logger.error('Failed to probe active recording tab', error);
|
||||
scheduleRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isActiveTab) {
|
||||
scheduleRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
|
||||
if (!currentWorkspace) {
|
||||
// Workspace can lag behind the post-recording status update for a short
|
||||
// time; keep retrying instead of permanently failing the import.
|
||||
scheduleRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
processingStatusId = status.id;
|
||||
|
||||
try {
|
||||
await createRecordingDoc(
|
||||
frameworkProvider,
|
||||
currentWorkspace.workspace,
|
||||
status
|
||||
);
|
||||
await apis?.recording.setRecordingBlockCreationStatus(
|
||||
status.id,
|
||||
'success'
|
||||
);
|
||||
clearPending(status.id);
|
||||
} catch (error) {
|
||||
logger.error('Failed to create recording block', error);
|
||||
try {
|
||||
await apis?.recording.setRecordingBlockCreationStatus(
|
||||
status.id,
|
||||
'failed',
|
||||
error instanceof Error ? error.message : undefined
|
||||
);
|
||||
} finally {
|
||||
clearPending(status.id);
|
||||
}
|
||||
} finally {
|
||||
if (pendingStatus?.id === status.id) {
|
||||
processingStatusId = null;
|
||||
scheduleRetry();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
events?.recording.onRecordingStatusChanged(status => {
|
||||
if (shouldProcessRecording(status)) {
|
||||
pendingStatus = status;
|
||||
clearRetry();
|
||||
void processPendingStatus().catch(console.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
clearPending();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingStatus?.id === status.id) {
|
||||
clearPending(status.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,28 +1,17 @@
|
||||
import { Button } from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { appIconMap } from '@affine/core/utils';
|
||||
import {
|
||||
createStreamEncoder,
|
||||
encodeRawBufferToOpus,
|
||||
type OpusStreamEncoder,
|
||||
} from '@affine/core/utils/opus-encoding';
|
||||
import { apis, events } from '@affine/electron-api';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import track from '@affine/track';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
|
||||
type Status = {
|
||||
id: number;
|
||||
status:
|
||||
| 'new'
|
||||
| 'recording'
|
||||
| 'paused'
|
||||
| 'stopped'
|
||||
| 'ready'
|
||||
| 'create-block-success'
|
||||
| 'create-block-failed';
|
||||
status: 'new' | 'recording' | 'processing' | 'ready';
|
||||
blockCreationStatus?: 'success' | 'failed';
|
||||
appName?: string;
|
||||
appGroupId?: number;
|
||||
icon?: Buffer;
|
||||
@@ -58,6 +47,7 @@ const appIcon = appIconMap[BUILD_CONFIG.appBuildType];
|
||||
|
||||
export function Recording() {
|
||||
const status = useRecordingStatus();
|
||||
const trackedNewRecordingIdsRef = useRef<Set<number>>(new Set());
|
||||
|
||||
const t = useI18n();
|
||||
const textElement = useMemo(() => {
|
||||
@@ -66,14 +56,19 @@ export function Recording() {
|
||||
}
|
||||
if (status.status === 'new') {
|
||||
return t['com.affine.recording.new']();
|
||||
} else if (status.status === 'create-block-success') {
|
||||
} else if (
|
||||
status.status === 'ready' &&
|
||||
status.blockCreationStatus === 'success'
|
||||
) {
|
||||
return t['com.affine.recording.success.prompt']();
|
||||
} else if (status.status === 'create-block-failed') {
|
||||
} else if (
|
||||
status.status === 'ready' &&
|
||||
status.blockCreationStatus === 'failed'
|
||||
) {
|
||||
return t['com.affine.recording.failed.prompt']();
|
||||
} else if (
|
||||
status.status === 'recording' ||
|
||||
status.status === 'ready' ||
|
||||
status.status === 'stopped'
|
||||
status.status === 'processing'
|
||||
) {
|
||||
if (status.appName) {
|
||||
return t['com.affine.recording.recording']({
|
||||
@@ -105,106 +100,16 @@ export function Recording() {
|
||||
await apis?.recording?.stopRecording(status.id);
|
||||
}, [status]);
|
||||
|
||||
const handleProcessStoppedRecording = useAsyncCallback(
|
||||
async (currentStreamEncoder?: OpusStreamEncoder) => {
|
||||
let id: number | undefined;
|
||||
try {
|
||||
const result = await apis?.recording?.getCurrentRecording();
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
id = result.id;
|
||||
|
||||
const { filepath, sampleRate, numberOfChannels } = result;
|
||||
if (!filepath || !sampleRate || !numberOfChannels) {
|
||||
return;
|
||||
}
|
||||
const [buffer] = await Promise.all([
|
||||
currentStreamEncoder
|
||||
? currentStreamEncoder.finish()
|
||||
: encodeRawBufferToOpus({
|
||||
filepath,
|
||||
sampleRate,
|
||||
numberOfChannels,
|
||||
}),
|
||||
new Promise<void>(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, 500); // wait at least 500ms for better user experience
|
||||
}),
|
||||
]);
|
||||
await apis?.recording.readyRecording(result.id, buffer);
|
||||
} catch (error) {
|
||||
console.error('Failed to stop recording', error);
|
||||
await apis?.popup?.dismissCurrentRecording();
|
||||
if (id) {
|
||||
await apis?.recording.removeRecording(id);
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let removed = false;
|
||||
let currentStreamEncoder: OpusStreamEncoder | undefined;
|
||||
if (!status || status.status !== 'new') return;
|
||||
if (trackedNewRecordingIdsRef.current.has(status.id)) return;
|
||||
|
||||
apis?.recording
|
||||
.getCurrentRecording()
|
||||
.then(status => {
|
||||
if (status) {
|
||||
return handleRecordingStatusChanged(status);
|
||||
}
|
||||
return;
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
const handleRecordingStatusChanged = async (status: Status) => {
|
||||
if (removed) {
|
||||
return;
|
||||
}
|
||||
if (status?.status === 'new') {
|
||||
track.popup.$.recordingBar.toggleRecordingBar({
|
||||
type: 'Meeting record',
|
||||
appName: status.appName || 'System Audio',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
status?.status === 'recording' &&
|
||||
status.sampleRate &&
|
||||
status.numberOfChannels &&
|
||||
(!currentStreamEncoder || currentStreamEncoder.id !== status.id)
|
||||
) {
|
||||
currentStreamEncoder?.close();
|
||||
currentStreamEncoder = createStreamEncoder(status.id, {
|
||||
sampleRate: status.sampleRate,
|
||||
numberOfChannels: status.numberOfChannels,
|
||||
});
|
||||
currentStreamEncoder.poll().catch(console.error);
|
||||
}
|
||||
|
||||
if (status?.status === 'stopped') {
|
||||
handleProcessStoppedRecording(currentStreamEncoder);
|
||||
currentStreamEncoder = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// allow processing stopped event in tray menu as well:
|
||||
const unsubscribe = events?.recording.onRecordingStatusChanged(status => {
|
||||
if (status) {
|
||||
handleRecordingStatusChanged(status).catch(console.error);
|
||||
}
|
||||
trackedNewRecordingIdsRef.current.add(status.id);
|
||||
track.popup.$.recordingBar.toggleRecordingBar({
|
||||
type: 'Meeting record',
|
||||
appName: status.appName || 'System Audio',
|
||||
});
|
||||
|
||||
return () => {
|
||||
removed = true;
|
||||
unsubscribe?.();
|
||||
currentStreamEncoder?.close();
|
||||
};
|
||||
}, [handleProcessStoppedRecording]);
|
||||
}, [status]);
|
||||
|
||||
const handleStartRecording = useAsyncCallback(async () => {
|
||||
if (!status) {
|
||||
@@ -249,7 +154,10 @@ export function Recording() {
|
||||
{t['com.affine.recording.stop']()}
|
||||
</Button>
|
||||
);
|
||||
} else if (status.status === 'stopped' || status.status === 'ready') {
|
||||
} else if (
|
||||
status.status === 'processing' ||
|
||||
(status.status === 'ready' && !status.blockCreationStatus)
|
||||
) {
|
||||
return (
|
||||
<Button
|
||||
variant="error"
|
||||
@@ -258,13 +166,19 @@ export function Recording() {
|
||||
disabled
|
||||
/>
|
||||
);
|
||||
} else if (status.status === 'create-block-success') {
|
||||
} else if (
|
||||
status.status === 'ready' &&
|
||||
status.blockCreationStatus === 'success'
|
||||
) {
|
||||
return (
|
||||
<Button variant="primary" onClick={handleDismiss}>
|
||||
{t['com.affine.recording.success.button']()}
|
||||
</Button>
|
||||
);
|
||||
} else if (status.status === 'create-block-failed') {
|
||||
} else if (
|
||||
status.status === 'ready' &&
|
||||
status.blockCreationStatus === 'failed'
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<Button variant="plain" onClick={handleDismiss}>
|
||||
|
||||
Reference in New Issue
Block a user