mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-21 12:06:35 +08:00
feat(native): async recorder (#14700)
#### PR Dependency Tree * **PR #14700** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Durable, resumable import queue with explicit import lifecycle and updated popup/tray status behavior. * Async native recording APIs and ability to abort recordings; audio quality metrics (degraded, overflow count). * Added "Importing..." translation. * **Bug Fixes** * More reliable single-claim import processing, retries and cleanup to avoid duplicate imports. * Improved stop/abort teardown stability and safer shutdown behavior. * **Tests** * New/updated tests covering coordinator, import queue, native async flows and teardown scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -7,22 +7,33 @@ import { apis, events } from '@affine/electron-api';
|
||||
import { i18nTime } from '@affine/i18n';
|
||||
import track from '@affine/track';
|
||||
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
|
||||
import type { Store } from '@blocksuite/affine/store';
|
||||
import type { BlobEngine } from '@blocksuite/affine/sync';
|
||||
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 RECORDING_IMPORT_RETRY_MS = 1000;
|
||||
const NATIVE_RECORDING_MIME_TYPE = 'audio/ogg';
|
||||
|
||||
type ProcessingRecordingStatus = {
|
||||
type RecordingImportStatus = {
|
||||
id: number;
|
||||
status: 'processing';
|
||||
appName?: string;
|
||||
blockCreationStatus?: undefined;
|
||||
workspaceId?: string;
|
||||
docId?: string;
|
||||
filepath: string;
|
||||
startTime: number;
|
||||
sampleRate?: number;
|
||||
numberOfChannels?: number;
|
||||
durationMs?: number;
|
||||
size?: number;
|
||||
degraded?: boolean;
|
||||
overflowCount?: number;
|
||||
errorMessage?: string;
|
||||
importStatus: 'pending_import' | 'importing' | 'imported' | 'import_failed';
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
type WorkspaceHandle = NonNullable<ReturnType<typeof getCurrentWorkspace>>;
|
||||
@@ -65,29 +76,7 @@ async function saveRecordingBlob(blobEngine: BlobEngine, filepath: string) {
|
||||
return { blob, blobId };
|
||||
}
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
async function createRecordingDoc(
|
||||
frameworkProvider: FrameworkProvider,
|
||||
workspace: WorkspaceHandle['workspace'],
|
||||
status: ProcessingRecordingStatus
|
||||
) {
|
||||
const docsService = workspace.scope.get(DocsService);
|
||||
const aiEnabled = isAiEnabled(frameworkProvider);
|
||||
const recordingFilepath = status.filepath;
|
||||
|
||||
function getAttachmentName(status: RecordingImportStatus) {
|
||||
const timestamp = i18nTime(status.startTime, {
|
||||
absolute: {
|
||||
accuracy: 'minute',
|
||||
@@ -95,77 +84,145 @@ async function createRecordingDoc(
|
||||
},
|
||||
});
|
||||
|
||||
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
|
||||
);
|
||||
return {
|
||||
timestamp,
|
||||
attachmentName:
|
||||
(status.appName ?? 'System Audio') + ' ' + timestamp + '.opus',
|
||||
};
|
||||
}
|
||||
|
||||
// name + timestamp(readable) + extension
|
||||
const attachmentName =
|
||||
(status.appName ?? 'System Audio') + ' ' + timestamp + '.opus';
|
||||
function ensureNoteId(docStore: Store) {
|
||||
const [existingNote] = docStore.getModelsByFlavour('affine:note');
|
||||
if (existingNote) {
|
||||
return existingNote.id;
|
||||
}
|
||||
|
||||
const attachmentId = doc.addBlock(
|
||||
'affine:attachment',
|
||||
{
|
||||
name: attachmentName,
|
||||
type: NATIVE_RECORDING_MIME_TYPE,
|
||||
size: blob.size,
|
||||
sourceId: blobId,
|
||||
embed: true,
|
||||
},
|
||||
noteId
|
||||
);
|
||||
const [page] = docStore.getModelsByFlavour('affine:page');
|
||||
if (!page) {
|
||||
throw new Error('Recording doc is missing the page block');
|
||||
}
|
||||
|
||||
const model = doc.getBlock(attachmentId)
|
||||
?.model as AttachmentBlockModel;
|
||||
return docStore.addBlock('affine:note', {}, page.id);
|
||||
}
|
||||
|
||||
if (!aiEnabled) {
|
||||
return;
|
||||
}
|
||||
function findExistingAttachment(docStore: Store, attachmentName: string) {
|
||||
return (
|
||||
docStore.getModelsByFlavour('affine:attachment') as AttachmentBlockModel[]
|
||||
).find(
|
||||
model =>
|
||||
model.props.name === attachmentName &&
|
||||
model.props.type === NATIVE_RECORDING_MIME_TYPE
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
})().then(resolve, reject);
|
||||
},
|
||||
};
|
||||
async function createRecordingDoc(
|
||||
frameworkProvider: FrameworkProvider,
|
||||
workspace: WorkspaceHandle['workspace'],
|
||||
status: RecordingImportStatus
|
||||
) {
|
||||
if (!status.docId) {
|
||||
throw new Error('Recording import is missing docId');
|
||||
}
|
||||
|
||||
const page = docsService.createDoc({
|
||||
const docsService = workspace.scope.get(DocsService);
|
||||
const aiEnabled = isAiEnabled(frameworkProvider);
|
||||
const { attachmentName, timestamp } = getAttachmentName(status);
|
||||
const targetDocId = status.docId;
|
||||
const docExists = !!docsService.list.doc$(targetDocId).value;
|
||||
|
||||
if (!docExists) {
|
||||
const docProps: DocProps = {};
|
||||
docsService.createDoc({
|
||||
id: targetDocId,
|
||||
docProps,
|
||||
title:
|
||||
'Recording ' + (status.appName ?? 'System Audio') + ' ' + timestamp,
|
||||
primaryMode: 'page',
|
||||
});
|
||||
workspace.scope.get(WorkbenchService).workbench.openDoc(page.id);
|
||||
});
|
||||
}
|
||||
|
||||
const { doc, release } = docsService.open(targetDocId);
|
||||
const disposePriorityLoad = doc.addPriorityLoad(10);
|
||||
|
||||
try {
|
||||
try {
|
||||
await doc.waitForSyncReady();
|
||||
} finally {
|
||||
disposePriorityLoad();
|
||||
}
|
||||
|
||||
const noteId = ensureNoteId(doc.blockSuiteDoc);
|
||||
const existingAttachment = findExistingAttachment(
|
||||
doc.blockSuiteDoc,
|
||||
attachmentName
|
||||
);
|
||||
|
||||
let model = existingAttachment;
|
||||
let attachmentCreated = false;
|
||||
|
||||
if (!model) {
|
||||
const { blobId, blob } = await saveRecordingBlob(
|
||||
workspace.docCollection.blobSync,
|
||||
status.filepath
|
||||
);
|
||||
|
||||
const attachmentId = doc.blockSuiteDoc.addBlock(
|
||||
'affine:attachment',
|
||||
{
|
||||
name: attachmentName,
|
||||
type: NATIVE_RECORDING_MIME_TYPE,
|
||||
size: blob.size,
|
||||
sourceId: blobId,
|
||||
embed: true,
|
||||
},
|
||||
noteId
|
||||
);
|
||||
|
||||
model = doc.blockSuiteDoc.getBlock(attachmentId)?.model as
|
||||
| AttachmentBlockModel
|
||||
| undefined;
|
||||
if (!model) {
|
||||
throw new Error('Failed to create recording attachment');
|
||||
}
|
||||
attachmentCreated = true;
|
||||
}
|
||||
|
||||
workspace.scope.get(WorkbenchService).workbench.openDoc(targetDocId);
|
||||
|
||||
if (!aiEnabled || !attachmentCreated) {
|
||||
return;
|
||||
}
|
||||
|
||||
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
|
||||
if (!currentWorkspace) {
|
||||
return;
|
||||
}
|
||||
const { workspace: currentWorkspaceEntity } = currentWorkspace;
|
||||
using audioAttachment = currentWorkspaceEntity.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);
|
||||
});
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
export function setupRecordingEvents(frameworkProvider: FrameworkProvider) {
|
||||
let pendingStatus: ProcessingRecordingStatus | null = null;
|
||||
let importQueue: RecordingImportStatus[] = [];
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let processingStatusId: number | null = null;
|
||||
let isProcessingImport = false;
|
||||
let hasSeenLiveQueueUpdate = false;
|
||||
|
||||
const clearRetry = () => {
|
||||
if (retryTimer !== null) {
|
||||
@@ -174,101 +231,130 @@ export function setupRecordingEvents(frameworkProvider: FrameworkProvider) {
|
||||
}
|
||||
};
|
||||
|
||||
const clearPending = (id?: number) => {
|
||||
if (id === undefined || pendingStatus?.id === id) {
|
||||
pendingStatus = null;
|
||||
clearRetry();
|
||||
}
|
||||
if (id === undefined || processingStatusId === id) {
|
||||
processingStatusId = null;
|
||||
}
|
||||
const updateQueue = (nextQueue: RecordingImportStatus[]) => {
|
||||
importQueue = nextQueue;
|
||||
};
|
||||
|
||||
const scheduleRetry = () => {
|
||||
if (!pendingStatus || retryTimer !== null) {
|
||||
if (importQueue.length === 0 || retryTimer !== null) {
|
||||
return;
|
||||
}
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
void processPendingStatus().catch(console.error);
|
||||
}, RECORDING_PROCESS_RETRY_MS);
|
||||
void processNextImport().catch(console.error);
|
||||
}, RECORDING_IMPORT_RETRY_MS);
|
||||
};
|
||||
|
||||
const processPendingStatus = async () => {
|
||||
const status = pendingStatus;
|
||||
if (!status || processingStatusId === status.id) {
|
||||
const processNextImport = async () => {
|
||||
if (isProcessingImport) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isActiveTab = false;
|
||||
isProcessingImport = true;
|
||||
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);
|
||||
let isActiveTab = false;
|
||||
try {
|
||||
await apis?.recording.setRecordingBlockCreationStatus(
|
||||
status.id,
|
||||
'failed',
|
||||
error instanceof Error ? error.message : undefined
|
||||
isActiveTab = !!(await apis?.ui.isActiveTab());
|
||||
} catch (error) {
|
||||
logger.error('Failed to probe active recording tab', error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isActiveTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
|
||||
if (!currentWorkspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceId = currentWorkspace.workspace.id;
|
||||
const nextStatus =
|
||||
importQueue.find(
|
||||
status =>
|
||||
status.importStatus === 'pending_import' &&
|
||||
(!status.workspaceId || status.workspaceId === workspaceId)
|
||||
) ??
|
||||
importQueue.find(
|
||||
status =>
|
||||
status.importStatus === 'importing' &&
|
||||
status.workspaceId === workspaceId
|
||||
) ??
|
||||
null;
|
||||
|
||||
if (!nextStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const claimed = await apis?.recording.claimRecordingImport(
|
||||
nextStatus.id,
|
||||
workspaceId
|
||||
);
|
||||
if (!claimed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createRecordingDoc(
|
||||
frameworkProvider,
|
||||
currentWorkspace.workspace,
|
||||
claimed
|
||||
);
|
||||
} catch (error) {
|
||||
const importError =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error('Failed to import recording artifact');
|
||||
logger.error('Failed to import recording artifact', importError);
|
||||
await apis?.recording.failRecordingImport(
|
||||
nextStatus.id,
|
||||
importError.message
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apis?.recording.completeRecordingImport(nextStatus.id);
|
||||
} catch (error) {
|
||||
const completionError =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error('Failed to persist recording import completion');
|
||||
logger.error(
|
||||
'Failed to persist recording import completion',
|
||||
completionError
|
||||
);
|
||||
await apis?.recording.failRecordingImport(
|
||||
nextStatus.id,
|
||||
completionError.message
|
||||
);
|
||||
} finally {
|
||||
clearPending(status.id);
|
||||
}
|
||||
} finally {
|
||||
if (pendingStatus?.id === status.id) {
|
||||
processingStatusId = null;
|
||||
scheduleRetry();
|
||||
}
|
||||
isProcessingImport = false;
|
||||
scheduleRetry();
|
||||
}
|
||||
};
|
||||
|
||||
events?.recording.onRecordingStatusChanged(status => {
|
||||
if (shouldProcessRecording(status)) {
|
||||
pendingStatus = status;
|
||||
clearRetry();
|
||||
void processPendingStatus().catch(console.error);
|
||||
return;
|
||||
}
|
||||
if (apis?.recording) {
|
||||
void apis.recording
|
||||
.getRecordingImportQueue()
|
||||
.then(queue => {
|
||||
if (hasSeenLiveQueueUpdate) {
|
||||
return;
|
||||
}
|
||||
updateQueue(queue ?? []);
|
||||
void processNextImport().catch(console.error);
|
||||
})
|
||||
.catch(error => {
|
||||
logger.error('Failed to load recording import queue', error);
|
||||
});
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
clearPending();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingStatus?.id === status.id) {
|
||||
clearPending(status.id);
|
||||
}
|
||||
events?.recording.onRecordingImportQueueChanged(queue => {
|
||||
hasSeenLiveQueueUpdate = true;
|
||||
updateQueue(queue);
|
||||
clearRetry();
|
||||
void processNextImport().catch(console.error);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,8 +10,17 @@ import * as styles from './styles.css';
|
||||
|
||||
type Status = {
|
||||
id: number;
|
||||
status: 'new' | 'recording' | 'processing' | 'ready';
|
||||
blockCreationStatus?: 'success' | 'failed';
|
||||
status:
|
||||
| 'new'
|
||||
| 'starting'
|
||||
| 'start_failed'
|
||||
| 'recording'
|
||||
| 'finalizing'
|
||||
| 'pending_import'
|
||||
| 'importing'
|
||||
| 'imported'
|
||||
| 'import_failed'
|
||||
| 'finalize_failed';
|
||||
appName?: string;
|
||||
appGroupId?: number;
|
||||
icon?: Buffer;
|
||||
@@ -56,19 +65,18 @@ export function Recording() {
|
||||
}
|
||||
if (status.status === 'new') {
|
||||
return t['com.affine.recording.new']();
|
||||
} else if (
|
||||
status.status === 'ready' &&
|
||||
status.blockCreationStatus === 'success'
|
||||
) {
|
||||
} else if (status.status === 'imported') {
|
||||
return t['com.affine.recording.success.prompt']();
|
||||
} else if (
|
||||
status.status === 'ready' &&
|
||||
status.blockCreationStatus === 'failed'
|
||||
status.status === 'import_failed' ||
|
||||
status.status === 'start_failed' ||
|
||||
status.status === 'finalize_failed'
|
||||
) {
|
||||
return t['com.affine.recording.failed.prompt']();
|
||||
} else if (
|
||||
status.status === 'starting' ||
|
||||
status.status === 'recording' ||
|
||||
status.status === 'processing'
|
||||
status.status === 'finalizing'
|
||||
) {
|
||||
if (status.appName) {
|
||||
return t['com.affine.recording.recording']({
|
||||
@@ -77,11 +85,19 @@ export function Recording() {
|
||||
} else {
|
||||
return t['com.affine.recording.recording.unnamed']();
|
||||
}
|
||||
} else if (
|
||||
status.status === 'pending_import' ||
|
||||
status.status === 'importing'
|
||||
) {
|
||||
return t['com.affine.recording.importing.prompt']();
|
||||
}
|
||||
return null;
|
||||
}, [status, t]);
|
||||
|
||||
const handleDismiss = useAsyncCallback(async () => {
|
||||
if (status) {
|
||||
await apis?.recording?.dismissRecordingStatus(status.id);
|
||||
}
|
||||
await apis?.popup?.dismissCurrentRecording();
|
||||
track.popup.$.recordingBar.dismissRecording({
|
||||
type: 'Meeting record',
|
||||
@@ -155,8 +171,10 @@ export function Recording() {
|
||||
</Button>
|
||||
);
|
||||
} else if (
|
||||
status.status === 'processing' ||
|
||||
(status.status === 'ready' && !status.blockCreationStatus)
|
||||
status.status === 'starting' ||
|
||||
status.status === 'finalizing' ||
|
||||
status.status === 'pending_import' ||
|
||||
status.status === 'importing'
|
||||
) {
|
||||
return (
|
||||
<Button
|
||||
@@ -166,18 +184,21 @@ export function Recording() {
|
||||
disabled
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
status.status === 'ready' &&
|
||||
status.blockCreationStatus === 'success'
|
||||
) {
|
||||
} else if (status.status === 'imported') {
|
||||
return (
|
||||
<Button variant="primary" onClick={handleDismiss}>
|
||||
{t['com.affine.recording.success.button']()}
|
||||
</Button>
|
||||
);
|
||||
} else if (status.status === 'start_failed') {
|
||||
return (
|
||||
<Button variant="plain" onClick={handleDismiss}>
|
||||
{t['com.affine.recording.dismiss']()}
|
||||
</Button>
|
||||
);
|
||||
} else if (
|
||||
status.status === 'ready' &&
|
||||
status.blockCreationStatus === 'failed'
|
||||
status.status === 'import_failed' ||
|
||||
status.status === 'finalize_failed'
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user