feat(editor): audio block (#10947)

AudioMedia entity for loading & controlling a single audio media
AudioMediaManagerService: Global audio state synchronization across tabs
AudioAttachmentService + AudioAttachmentBlock for manipulating AttachmentBlock in affine - e.g., filling transcription (using mock endpoint for now)
Added AudioBlock + AudioPlayer for rendering audio block in affine (new transcription block whose renderer is provided in affine)

fix AF-2292
fix AF-2337
This commit is contained in:
pengx17
2025-03-20 12:46:14 +00:00
parent 8a5393ea50
commit fad49bb070
120 changed files with 5407 additions and 950 deletions

View File

@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-misused-promises */
import { createServer } from 'node:http';
import path from 'node:path';
import {
type Application,
@@ -14,6 +15,8 @@ import chokidar from 'chokidar';
import express from 'express';
import rateLimit from 'express-rate-limit';
import fs from 'fs-extra';
import { debounce } from 'lodash-es';
import multer from 'multer';
import { Server } from 'socket.io';
import { gemini, type TranscriptionResult } from './gemini';
@@ -87,6 +90,20 @@ const io = new Server(httpServer, {
cors: { origin: '*' },
});
// Add CORS headers middleware
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header(
'Access-Control-Allow-Headers',
'Origin, X-Requested-With, Content-Type, Accept'
);
if (req.method === 'OPTIONS') {
return res.sendStatus(200);
}
next();
});
app.use(express.json());
// Update the static file serving to handle the new folder structure
@@ -116,6 +133,14 @@ app.use(
express.static(RECORDING_DIR)
);
// Configure multer for temporary file storage
const upload = multer({
dest: RECORDING_DIR,
limits: {
fileSize: 50 * 1024 * 1024, // 50MB limit
},
});
// Recording management
async function saveRecording(recording: Recording): Promise<string | null> {
try {
@@ -171,13 +196,23 @@ async function saveRecording(recording: Recording): Promise<string | null> {
const baseFilename = recording.isGlobal
? `global-recording-${timestamp}`
: `${app?.bundleIdentifier ?? 'unknown'}-${app?.processId ?? 0}-${timestamp}`;
const recordingDir = `${RECORDING_DIR}/${baseFilename}`;
// Sanitize the baseFilename to prevent path traversal
const sanitizedFilename = baseFilename
.replace(/[/\\:*?"<>|]/g, '') // Remove any filesystem special chars
.replace(/^\.+|\.+$/g, ''); // Remove leading/trailing dots
// Use path.join for safe path construction
const recordingDir = path.join(RECORDING_DIR, sanitizedFilename);
await fs.ensureDir(recordingDir);
const mp3Filename = `${recordingDir}/recording.mp3`;
const transcriptionMp3Filename = `${recordingDir}/transcription.mp3`;
const metadataFilename = `${recordingDir}/metadata.json`;
const iconFilename = `${recordingDir}/icon.png`;
const mp3Filename = path.join(recordingDir, 'recording.mp3');
const transcriptionMp3Filename = path.join(
recordingDir,
'transcription.mp3'
);
const metadataFilename = path.join(recordingDir, 'metadata.json');
const iconFilename = path.join(recordingDir, 'icon.png');
// Save MP3 file with the actual sample rate from the stream
console.log(`📝 Writing MP3 file to ${mp3Filename}`);
@@ -258,7 +293,9 @@ async function startRecording(app: TappableApplication) {
try {
const processGroupId = app.processGroupId;
const rootApp = shareableContent.applicationWithProcessId(processGroupId);
const rootApp =
shareableContent.applicationWithProcessId(processGroupId) ||
shareableContent.applicationWithProcessId(app.processId);
if (!rootApp) {
console.error(`❌ App group not found for ${app.name}`);
return;
@@ -520,6 +557,8 @@ async function getAllApps(): Promise<AppInfo[]> {
})
);
listenToAppStateChanges(filteredApps);
return filteredApps;
}
@@ -529,25 +568,29 @@ function listenToAppStateChanges(apps: AppInfo[]) {
if (!app) {
return { unsubscribe: () => {} };
}
return ShareableContent.onAppStateChanged(app, () => {
setTimeout(() => {
console.log(
`🔄 Application state changed: ${app.name} (PID: ${app.processId}) is now ${
app.isRunning ? '▶️ running' : '⏹️ stopped'
}`
);
io.emit('apps:state-changed', {
processId: app.processId,
isRunning: app.isRunning,
});
if (!app.isRunning) {
stopRecording(app.processId).catch(error => {
console.error('❌ Error stopping recording:', error);
});
}
}, 100);
});
const onAppStateChanged = () => {
console.log(
`🔄 Application state changed: ${app.name} (PID: ${app.processId}) is now ${
app.isRunning ? '▶️ running' : '⏹️ stopped'
}`
);
io.emit('apps:state-changed', {
processId: app.processId,
isRunning: app.isRunning,
});
if (!app.isRunning) {
stopRecording(app.processId).catch(error => {
console.error('❌ Error stopping recording:', error);
});
}
};
return ShareableContent.onAppStateChanged(
app,
debounce(onAppStateChanged, 500)
);
} catch (error) {
console.error(
`Failed to listen to app state changes for ${app?.name}:`,
@@ -635,8 +678,16 @@ function validateAndSanitizeFolderName(folderName: string): string | null {
return null;
}
// Remove any path traversal attempts
const sanitized = folderName.replace(/^\.+|\.+$/g, '').replace(/[/\\]/g, '');
// Remove any path traversal attempts and disallow any special characters
const sanitized = folderName
.replace(/^\.+|\.+$/g, '') // Remove leading/trailing dots
.replace(/[/\\:*?"<>|]/g, ''); // Remove any filesystem special chars
// Double-check the sanitized result still matches our expected pattern
if (!/^([\w.-]+-\d+-\d+|global-recording-\d+)$/.test(sanitized)) {
return null;
}
return sanitized;
}
@@ -647,7 +698,8 @@ app.delete('/recordings/:foldername', rateLimiter, async (req, res) => {
return res.status(400).json({ error: 'Invalid folder name format' });
}
const recordingDir = `${RECORDING_DIR}/${foldername}`;
// Construct the path safely using path.join to avoid path traversal
const recordingDir = path.join(RECORDING_DIR, foldername);
try {
// Ensure the resolved path is within RECORDING_DIR
@@ -812,6 +864,41 @@ app.post(
}
);
app.post(
'/transcribe',
rateLimiter,
upload.single('audio') as any,
async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No audio file provided' });
}
// Notify clients that transcription has started
io.emit('apps:recording-transcription-start', { filename: 'temp' });
const transcription = await gemini(req.file.path, {
mode: 'transcript',
});
res.json({ success: true, transcription });
} catch (error) {
console.error('❌ Error during transcription:', error);
// Notify clients of transcription error
io.emit('apps:recording-transcription-end', {
filename: 'temp',
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
});
res.status(500).json({
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
async function startGlobalRecording() {
const GLOBAL_RECORDING_ID = -1;
if (recordingMap.has(GLOBAL_RECORDING_ID)) {