Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion client/electron/ai/handlers/sessionAnalyzeHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ function register(): void {
audioText: string | null;
}>;
duration: number; // 已转为秒
/** 分析模式:full 全量 / partial 增量片段(网关据此选择 Prompt 模板) */
mode?: 'full' | 'partial';
language?: string;
authToken?: string;
userApiKey?: string;
Expand All @@ -45,7 +47,7 @@ function register(): void {
const startMs = Date.now();
const kfCount = args.keyframes?.length ?? 0;
const segCount = args.audioSegments?.length ?? 0;
logger.info(`[AI] [session-analyze] IPC received: keyframes=${kfCount}, audioSegments=${segCount}, duration=${args.duration}s, language=${args.language ?? 'zh'}, hasAuth=${!!args.authToken}`);
logger.info(`[AI] [session-analyze] IPC received: keyframes=${kfCount}, audioSegments=${segCount}, duration=${args.duration}s, mode=${args.mode ?? 'full'}, language=${args.language ?? 'zh'}, hasAuth=${!!args.authToken}`);

const reqBody = {
keyframes: args.keyframes.map(kf => ({
Expand All @@ -59,6 +61,7 @@ function register(): void {
audio_text: seg.audioText,
})),
duration: args.duration,
mode: args.mode ?? 'full',
language: args.language ?? 'zh',
};

Expand Down
23 changes: 21 additions & 2 deletions client/electron/audioCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
* 5. PCM 数据块通过 IPC 回传主进程,添加单调时间戳后推送给消费者
*
* @ai-context: 系统音频捕获:渲染进程 getDisplayMedia 采集、主进程聚合分块。
*
* TODO(现场课程): 当前仅支持系统音频环回(捕获电脑播放的声音,适配网课场景)。
* 后续「现场课程」需扩展麦克风输入源:listAudioSources 增加枚举
* navigator.mediaDevices 的 audioinput 设备,getUserMedia 直接以 deviceId
* 采集麦克风,并与环回源并列供用户选择(或双路混合)。
*/

import { desktopCapturer, DesktopCapturerSource, BrowserWindow } from 'electron';
Expand Down Expand Up @@ -122,14 +127,18 @@ export class AudioCapture {
* 开始音频捕获
*
* 1. 如果未指定 sourceId,自动选择第一个可用的系统音频源
* 2. 向渲染进程发送启动指令(含 sourceId + 配置)
* 3. 渲染进程负责 getUserMedia 和音频切片
* 2. 方案A:若指定的是窗口源(window:xxx),优先尝试窗口级音频捕获,
* 同时解析一个屏幕环回源作为降级候选(Windows 上窗口源音频支持
* 依赖 Chromium 版本,失败时渲染进程自动回退环回源)
* 3. 向渲染进程发送启动指令(含 sourceId + fallbackSourceId + 配置)
* 4. 渲染进程负责 getUserMedia 和音频切片
*/
async start(win: BrowserWindow, sourceId?: string): Promise<void> {
if (this.capturing || this.disposed) return;

// 解析音频源
let resolvedSourceId = sourceId ?? null;
let fallbackSourceId: string | null = null;
if (!resolvedSourceId) {
const sources = await listAudioSources();
if (sources.length === 0) {
Expand All @@ -138,6 +147,15 @@ export class AudioCapture {
}
resolvedSourceId = sources[0].id;
logger.info(`[AudioCapture] 自动选择音频源: ${sources[0].name} (${resolvedSourceId})`);
} else if (resolvedSourceId.startsWith('window:')) {
// 方案A:窗口源直采目标应用(如 B站客户端/浏览器)的音频,
// 预解析屏幕环回源作为降级候选,防御窗口级捕获不受支持的环境
const sources = await listAudioSources();
fallbackSourceId = sources[0]?.id ?? null;
logger.info(
`[AudioCapture] 方案A 窗口源音频捕获: ${resolvedSourceId}, ` +
`降级候选: ${fallbackSourceId ?? '无'}`,
);
}

this.capturing = true;
Expand All @@ -153,6 +171,7 @@ export class AudioCapture {
if (!win.isDestroyed()) {
win.webContents.send('audio_capture_do_start', {
sourceId: resolvedSourceId,
fallbackSourceId,
options: this.options,
});
}
Expand Down
5 changes: 3 additions & 2 deletions client/electron/cspPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,10 @@ export function installCspPolicy(isDev: boolean): void {
const extraConnectSrc = buildExtraConnectSrc();
// 开发环境:允许 unsafe-inline/unsafe-eval(Vite HMR 需要),worker-src 需要 blob:
// 生产环境:禁止 unsafe-eval,保留 unsafe-inline(Tailwind 运行时需要)
// img-src 放行 keyframe:(课堂关键帧本地图片自定义协议,见 ipc/keyframeStorage.ts)
const csp = isDev
? `default-src 'self' 'unsafe-inline' 'unsafe-eval' http://localhost:* ws://localhost:*; worker-src 'self' blob: http://localhost:*; connect-src 'self' http://localhost:* ws://localhost:* https://*.supabase.co wss://*.supabase.co https://entropydecrease.com wss://entropydecrease.com${extraConnectSrc}; img-src 'self' data: blob: https://*.supabase.co; font-src 'self' data:;`
: `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.supabase.co; font-src 'self' data:; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://entropydecrease.com wss://entropydecrease.com${extraConnectSrc}; frame-ancestors 'none';`;
? `default-src 'self' 'unsafe-inline' 'unsafe-eval' http://localhost:* ws://localhost:*; worker-src 'self' blob: http://localhost:*; connect-src 'self' http://localhost:* ws://localhost:* https://*.supabase.co wss://*.supabase.co https://entropydecrease.com wss://entropydecrease.com${extraConnectSrc}; img-src 'self' data: blob: keyframe: https://*.supabase.co; font-src 'self' data:;`
: `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: keyframe: https://*.supabase.co; font-src 'self' data:; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://entropydecrease.com wss://entropydecrease.com${extraConnectSrc}; frame-ancestors 'none';`;

callback({
responseHeaders: {
Expand Down
4 changes: 4 additions & 0 deletions client/electron/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ export const IPC_CHANNELS = {
// 文件系统
FS_READ_FILE: 'fs:read-file',

// 课堂关键帧图片持久化
KEYFRAME_SAVE: 'keyframe_save',
KEYFRAME_CLEANUP: 'keyframe_cleanup',

// 存储路径
STORAGE_CHANGE_PATH: 'storage:change-path',
STORAGE_GET_ACTIVE_PATH: 'storage:get-active-path',
Expand Down
113 changes: 113 additions & 0 deletions client/electron/ipc/keyframeStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* 课堂关键帧图片持久化 IPC + keyframe:// 自定义协议
* Classroom keyframe image persistence IPC handlers and custom
* keyframe:// protocol for renderer-side image loading.
*
* @ai-context: 关键帧 JPEG 本地落盘到 {userData}/captures/{sessionId}/{keyframeId}.jpg,
* 遵守本地优先原则(图片不上传)。渲染进程通过自定义 keyframe:// 协议加载:
* 开发页面源为 http://localhost、生产为 file://,file:// 子资源会被
* webSecurity/CSP 拦截,故注册 standard+secure 专用协议(CSP img-src 已放行 keyframe:)。
* (Custom protocol is used because file:// subresources are blocked by
* webSecurity/CSP under both dev http origin and prod file origin.)
* @ai-context: sessionId/keyframeId 仅允许 UUID/安全字符(防路径穿越);
* keyframe_cleanup 删除整个会话目录,由笔记删除路径调用(失败静默)。
*/
import { app, net, protocol } from 'electron';
import * as path from 'path';
import { mkdir, rm, writeFile } from 'fs/promises';
import { pathToFileURL } from 'url';
import { safeHandle } from '../ipcUtils.js';
import { logger } from '../logger.js';

/** 自定义协议名与固定 host(keyframe://capture/{sessionId}/{keyframeId}.jpg) */
const KEYFRAME_SCHEME = 'keyframe';
const KEYFRAME_HOST = 'capture';

/** 安全 ID 校验:仅允许 UUID/字母数字/下划线/连字符,最长 64(防路径穿越) */
const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;

function isSafeId(id: unknown): id is string {
return typeof id === 'string' && SAFE_ID_RE.test(id);
}

/** 关键帧图片根目录:{userData}/captures */
function capturesRoot(): string {
return path.join(app.getPath('userData'), 'captures');
}

/**
* 注册 keyframe:// 为特权 scheme。
* 必须在 app ready 之前调用(main.ts 模块顶层)。
* (Must be called before app ready.)
*/
export function registerKeyframeScheme(): void {
protocol.registerSchemesAsPrivileged([
{
scheme: KEYFRAME_SCHEME,
privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true },
},
]);
}

/**
* 注册 keyframe:// 协议 handler 与 keyframe_save / keyframe_cleanup IPC。
* app ready 后调用一次(main.ts whenReady 中)。
*/
export function registerKeyframeIpcHandlers(): void {
// ---- keyframe:// 协议:映射到本地 captures 目录(只读) ----
if (protocol.isProtocolHandled(KEYFRAME_SCHEME)) {
protocol.unhandle(KEYFRAME_SCHEME);
}
protocol.handle(KEYFRAME_SCHEME, async (request) => {
try {
const url = new URL(request.url);
const [sessionId, fileName] = url.pathname.replace(/^\//, '').split('/');
const keyframeId = fileName?.endsWith('.jpg') ? fileName.slice(0, -4) : undefined;
if (url.hostname !== KEYFRAME_HOST || !isSafeId(sessionId) || !isSafeId(keyframeId)) {
return new Response('Bad Request', { status: 400 });
}
const filePath = path.join(capturesRoot(), sessionId, `${keyframeId}.jpg`);
return await net.fetch(pathToFileURL(filePath).toString());
} catch (err) {
logger.warn(`[Keyframe] Protocol request failed: ${String(err)}`);
return new Response('Not Found', { status: 404 });
}
});

// ---- 保存关键帧 JPEG(返回可渲染 URL)----
safeHandle(
'keyframe_save',
async (_event, args: { sessionId: string; keyframeId: string; imageBase64: string }) => {
const { sessionId, keyframeId, imageBase64 } = args ?? {};
if (!isSafeId(sessionId) || !isSafeId(keyframeId)) {
throw new Error('非法的 sessionId/keyframeId');
}
if (typeof imageBase64 !== 'string' || imageBase64.length === 0) {
throw new Error('imageBase64 不能为空');
}
// 容忍 data URL 前缀(tolerate optional data URL prefix)
const commaIdx = imageBase64.indexOf(',');
const rawBase64 = imageBase64.startsWith('data:') && commaIdx >= 0
? imageBase64.slice(commaIdx + 1)
: imageBase64;

const dir = path.join(capturesRoot(), sessionId);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, `${keyframeId}.jpg`), Buffer.from(rawBase64, 'base64'));
return {
success: true,
url: `${KEYFRAME_SCHEME}://${KEYFRAME_HOST}/${sessionId}/${keyframeId}.jpg`,
};
},
);

// ---- 清理会话目录(笔记删除时调用,失败由调用方静默处理)----
safeHandle('keyframe_cleanup', async (_event, args: { sessionId: string }) => {
const sessionId = args?.sessionId;
if (!isSafeId(sessionId)) {
throw new Error('非法的 sessionId');
}
await rm(path.join(capturesRoot(), sessionId), { recursive: true, force: true });
return { success: true };
});
}
6 changes: 6 additions & 0 deletions client/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { loadEnvironment } from './envLoader.js';
import { installCspPolicy } from './cspPolicy.js';
import { registerDbIpcHandlers } from './db/dbIpcHandlers.js';
import { registerStorageIpcHandlers } from './storageIpcHandlers.js';
import { registerKeyframeScheme, registerKeyframeIpcHandlers } from './ipc/keyframeStorage.js';

// ================================================================
// 性能优化:启用 GPU 光栅化与零拷贝
Expand All @@ -60,6 +61,9 @@ if (process.platform === 'darwin') {
// ================================================================
loadEnvironment(__dirname);

// keyframe:// 特权 scheme 注册(必须在 app ready 前,详见 ipc/keyframeStorage.ts)
registerKeyframeScheme();

// ================================================================
// 模块级状态
// ================================================================
Expand Down Expand Up @@ -139,6 +143,8 @@ if (!gotTheLock) {
// 数据访问与存储/备份 IPC(详见 db/dbIpcHandlers.ts、storageIpcHandlers.ts)
registerDbIpcHandlers();
registerStorageIpcHandlers();
// 课堂关键帧图片持久化 IPC + keyframe:// 协议(详见 ipc/keyframeStorage.ts)
registerKeyframeIpcHandlers();

// 隐藏默认 Electron 菜单栏
Menu.setApplicationMenu(null);
Expand Down
3 changes: 3 additions & 0 deletions client/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ const ALLOWED_CHANNELS = [
// AI 流式输出 IPC channel
'ai:stream:start',
'ai:stream:cancel',
// 课堂关键帧图片持久化 IPC channel
'keyframe_save',
'keyframe_cleanup',
] as const;

/** 允许渲染进程监听的事件 channel 白名单(主进程 → 渲染进程推送) */
Expand Down
Binary file added client/public/sounds/pomodoro_complete.wav
Binary file not shown.
Binary file removed client/public/sounds/pomodoro_tick.wav
Binary file not shown.
114 changes: 114 additions & 0 deletions client/src/features/classroom/hooks/useAudioRecovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* 课堂音频自动恢复 hook(静音诊断 / 窗口源回退 / 设备变更重启)
*
* @ai-context: 从 useClassroomAudio 拆出的独立恢复层。三条恢复路径:
* ①窗口源静音回退——Windows 上窗口源(window:xxx)getUserMedia 可能"成功但
* 给出纯静音轨"(不抛错,异常降级不会触发),SilenceTracker 检出连续静音后
* 自动 stop→start 以环回源重启(每会话仅回退一次);
* ②输出设备不匹配提示——已在环回源仍持续静音,说明视频声音输出到了
* 非默认设备,提示用户核对默认输出设备;
* ③设备变更重启——默认输出设备切换后环回仍绑定旧设备,devicechange 时
* 自动重启重新绑定。
* @ai-context: 仅依赖 refs 的稳定回调,重启经 restartingRef 互斥防止并发。
*/
import { useEffect, useRef, useCallback } from 'react';
import type { CaptureMode, SessionStatus } from '@/lib/capture';
import {
computeChunkRms, SilenceTracker,
getDefaultOutputDeviceLabel, subscribeDeviceChange,
} from '@/lib/audio/outputDeviceMonitor';

/** 与 useSessionControl 启动参数保持一致的音频采集配置 */
const AUDIO_START_OPTIONS = { chunkDurationMs: 5000, sampleRate: 16000, channels: 1 };
/** 重启前等待渲染端管道清理完成的时间 */
const RESTART_CLEANUP_DELAY_MS = 500;

interface UseAudioRecoveryOptions {
status: SessionStatus;
mode: CaptureMode;
/** 方案A:会话选中窗口的源 ID(回退环回后置空) */
audioSourceId?: string | null;
onNotify: (type: 'warning' | 'error', message: string) => void;
}

export function useAudioRecovery({ status, mode, audioSourceId, onNotify }: UseAudioRecoveryOptions) {
const notifyRef = useRef(onNotify);
notifyRef.current = onNotify;
/** 当前生效的音频源(窗口源回退环回后变为 undefined) */
const effectiveSourceRef = useRef<string | undefined>(audioSourceId ?? undefined);
const silenceTrackerRef = useRef(new SilenceTracker());
/** 本会话是否已执行过窗口源→环回回退 */
const fellBackRef = useRef(false);
const restartingRef = useRef(false);

const audioEnabled = status === 'capturing' && (mode === 'audio' || mode === 'mixed');

// 会话开始时重置恢复状态(audioSourceId 取会话启动瞬间的快照)
useEffect(() => {
if (status !== 'capturing') return;
effectiveSourceRef.current = audioSourceId ?? undefined;
fellBackRef.current = false;
silenceTrackerRef.current.reset();
// eslint-disable-next-line react-hooks/exhaustive-deps -- 仅会话开始时快照 audioSourceId
}, [status]);

/** stop→start 重启音频捕获(互斥),成功返回 true */
const restartCapture = useCallback(async (sourceId?: string): Promise<boolean> => {
if (!window.electronAPI || restartingRef.current) return false;
restartingRef.current = true;
try {
await window.electronAPI.invoke('audio_capture_stop');
await new Promise((r) => setTimeout(r, RESTART_CLEANUP_DELAY_MS));
const result = await window.electronAPI.invoke('audio_capture_start', {
...AUDIO_START_OPTIONS, sourceId,
}) as { success: boolean; error?: string };
if (result.success) silenceTrackerRef.current.reset();
else console.warn('[useAudioRecovery] 音频捕获重启失败:', result.error);
return result.success;
} catch (err) {
console.error('[useAudioRecovery] 音频捕获重启异常:', err);
return false;
} finally {
restartingRef.current = false;
}
}, []);

// 静音诊断:窗口源先自动回退环回;已是环回仍静音则提示核对输出设备
useEffect(() => {
if (!audioEnabled || !window.electronAPI) return;
const off = window.electronAPI.on('audio_capture_chunk', (...args: unknown[]) => {
const chunk = args[0] as { audioBuffer: ArrayBuffer };
if (!silenceTrackerRef.current.push(computeChunkRms(chunk.audioBuffer))) return;

const src = effectiveSourceRef.current;
if (src?.startsWith('window:') && !fellBackRef.current) {
// 方案A 漏洞补偿:窗口源"成功但静音"不抛错,只能靠静音检测触发回退
fellBackRef.current = true;
effectiveSourceRef.current = undefined;
console.warn('[useAudioRecovery] 窗口音频源持续静音,自动回退系统环回');
void restartCapture(undefined).then((ok) => {
if (ok) notifyRef.current('warning', '窗口音频源持续静音,已自动切换为系统环回捕获');
});
return;
}
void getDefaultOutputDeviceLabel().then((label) => {
notifyRef.current('warning',
`持续收到静音音频:请确认视频声音正在播放,且输出到系统默认设备${label ? `「${label}」` : ''}(音频捕获只能录到默认输出设备的声音)`);
});
});
return off;
}, [audioEnabled, restartCapture]);

// 设备变更自动重启:重新绑定新的默认输出设备
useEffect(() => {
if (!audioEnabled || !window.electronAPI) return;
const unsubscribe = subscribeDeviceChange(() => {
console.info('[useAudioRecovery] 检测到音频设备变更,自动重启音频捕获');
void restartCapture(effectiveSourceRef.current).then((ok) => {
if (ok) notifyRef.current('warning', '检测到音频输出设备变更,已自动重新绑定音频捕获');
else notifyRef.current('error', '音频设备变更后重启捕获失败,请停止后重新开始采集');
});
});
return unsubscribe;
}, [audioEnabled, restartCapture]);
}
Loading
Loading