Skip to content

Commit 76241a3

Browse files
committed
feat(feynman): E2 录音关联持久化与跨会话回放(userData/recordings)
1 parent 7eff581 commit 76241a3

4 files changed

Lines changed: 191 additions & 10 deletions

File tree

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* FeynmanRecorder 组件测试 / Tests for E2 recording persistence
3+
*
4+
* @ai-context: 覆盖 B5(E2) 契约——有 noteId 时停止录音持久化到
5+
* {userData}/recordings(stem=feynman-{noteId})、挂载时加载跨会话回放、
6+
* 删除时清理本地文件;无 noteId 保持会话内回放(不落盘)。electronAPI
7+
* 全 Mock,绝不触碰真实 IPC/麦克风。
8+
*/
9+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
10+
import { render, screen, cleanup, act, fireEvent } from '@testing-library/react';
11+
import { FeynmanRecorder } from './FeynmanRecorder';
12+
13+
const mocks = vi.hoisted(() => {
14+
const api = {
15+
invoke: vi.fn(),
16+
on: vi.fn(),
17+
recording: {
18+
save: vi.fn(),
19+
load: vi.fn(),
20+
delete: vi.fn(),
21+
},
22+
};
23+
return { api };
24+
});
25+
26+
beforeEach(() => {
27+
// 只注入 electronAPI 属性,不替换 window 本体(保留 React 的 Node instanceof 检查)
28+
Object.defineProperty(window, 'electronAPI', { value: mocks.api, configurable: true, writable: true });
29+
vi.clearAllMocks();
30+
// 默认 IPC 行为:ASR 可用、采集可启动
31+
mocks.api.invoke.mockImplementation(async (channel: string) => {
32+
switch (channel) {
33+
case 'local_asr_stream_available': return { available: true };
34+
case 'audio_capture_status': return { active: false };
35+
case 'audio_capture_start': return { success: true };
36+
case 'local_asr_stream_start': return { success: true };
37+
case 'local_asr_stream_stop': return { success: true };
38+
case 'audio_capture_stop': return { success: true };
39+
default: return {};
40+
}
41+
});
42+
mocks.api.on.mockReturnValue(() => {});
43+
mocks.api.recording.save.mockResolvedValue({ success: true });
44+
mocks.api.recording.load.mockResolvedValue({ success: false, notFound: true });
45+
mocks.api.recording.delete.mockResolvedValue({ success: true });
46+
});
47+
48+
afterEach(() => {
49+
cleanup();
50+
delete (window as { electronAPI?: unknown }).electronAPI;
51+
});
52+
53+
/** 取某 channel 上注册的渲染侧回调(事件模拟入口) */
54+
function handlerOf(channel: string): (...args: unknown[]) => void {
55+
const call = mocks.api.on.mock.calls.find((c) => c[0] === channel);
56+
if (!call) throw new Error(`no handler for ${channel}`);
57+
return call[1] as (...args: unknown[]) => void;
58+
}
59+
60+
async function recordOnce() {
61+
// 开始录音
62+
fireEvent.click(screen.getByRole('button', { name: /口头讲解/ }));
63+
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
64+
// 模拟一帧 PCM 音频块(1 秒 16kHz 单声道)
65+
act(() => {
66+
handlerOf('audio_capture_chunk')({ audioBuffer: new Float32Array(16000).buffer, sampleRate: 16000 });
67+
});
68+
// 停止录音
69+
fireEvent.click(screen.getByRole('button', { name: /停止讲解/ }));
70+
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
71+
}
72+
73+
describe('FeynmanRecorder (B5/E2 persistence)', () => {
74+
it('should persist recording to {userData}/recordings when noteId given', async () => {
75+
render(<FeynmanRecorder explanation="" onExplanationChange={() => {}} noteId="note-1" />);
76+
await act(async () => { await Promise.resolve(); });
77+
await recordOnce();
78+
// 停止后触发持久化:stem = feynman-{noteId}
79+
const saved = mocks.api.recording.save.mock.calls[0];
80+
expect(saved?.[0]).toBe('feynman-note-1');
81+
expect(typeof saved?.[1]).toBe('string');
82+
expect(saved?.[1].length).toBeGreaterThan(100); // 有效 base64 WAV
83+
});
84+
85+
it('should load persisted recording on mount for cross-session playback', async () => {
86+
mocks.api.recording.load.mockResolvedValue({
87+
success: true,
88+
base64: 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAgD4AAIA+AAABAAgAZGF0YQAAAAA=',
89+
});
90+
render(<FeynmanRecorder explanation="" onExplanationChange={() => {}} noteId="note-2" />);
91+
await act(async () => { await Promise.resolve(); });
92+
expect(mocks.api.recording.load).toHaveBeenCalledWith('feynman-note-2');
93+
// 加载成功后显示回放条
94+
expect(document.querySelector('audio')).not.toBeNull();
95+
});
96+
97+
it('should not persist when noteId missing (session-only playback)', async () => {
98+
render(<FeynmanRecorder explanation="" onExplanationChange={() => {}} />);
99+
await act(async () => { await Promise.resolve(); });
100+
await recordOnce();
101+
expect(mocks.api.recording.save).not.toHaveBeenCalled();
102+
});
103+
104+
it('should delete local file when playback removed', async () => {
105+
mocks.api.recording.load.mockResolvedValue({
106+
success: true,
107+
base64: 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAgD4AAIA+AAABAAgAZGF0YQAAAAA=',
108+
});
109+
render(<FeynmanRecorder explanation="" onExplanationChange={() => {}} noteId="note-3" />);
110+
await act(async () => { await Promise.resolve(); });
111+
fireEvent.click(screen.getByTitle('删除录音'));
112+
await act(async () => { await Promise.resolve(); });
113+
expect(mocks.api.recording.delete).toHaveBeenCalledWith('feynman-note-3');
114+
expect(document.querySelector('audio')).toBeNull();
115+
});
116+
117+
it('should wait for in-flight save before deleting local file (race guard)', async () => {
118+
// save 挂起不 resolve:删除必须先等 save 完成,防止文件删而复生
119+
let resolveSave: (v: unknown) => void = () => {};
120+
mocks.api.recording.save.mockImplementation(
121+
() => new Promise((r) => { resolveSave = r; }),
122+
);
123+
render(<FeynmanRecorder explanation="" onExplanationChange={() => {}} noteId="note-4" />);
124+
await act(async () => { await Promise.resolve(); });
125+
await recordOnce(); // save 已发起但未完成
126+
fireEvent.click(screen.getByTitle('删除录音'));
127+
expect(mocks.api.recording.delete).not.toHaveBeenCalled();
128+
await act(async () => {
129+
resolveSave({ success: true });
130+
await Promise.resolve();
131+
await Promise.resolve();
132+
});
133+
expect(mocks.api.recording.delete).toHaveBeenCalledWith('feynman-note-4');
134+
});
135+
});

‎client/src/features/feynman/components/FeynmanRecorder.tsx‎

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
* 费曼录音讲解组件(E2 录音 + 回放自评)
33
*
44
* @ai-context: step1 讲解区的"口头讲解"入口:麦克风采集 + 本地流式 ASR
5-
* 实时转写填入 explanation;同步累积音频块,停止后编码为会话内 WAV blob
6-
* 供回放自评(不持久化音频,仅持久化文本)。ASR 不可用时整个入口隐藏
7-
* (可选增强原则)。链路复用 audio_capture_start + local_asr_stream_start。
5+
* 实时转写填入 explanation;同步累积音频块,停止后编码为 WAV 并持久化
6+
* 到 {userData}/recordings(以 feynman-{noteId} 为 stem,本地优先不上传),
7+
* 跨会话回放可用。无 noteId 时保持会话内回放(不落盘)。ASR 不可用时
8+
* 整个入口隐藏(可选增强原则)。链路复用 audio_capture_start +
9+
* local_asr_stream_start + recording:save/load/delete。
810
*/
911
import { useCallback, useEffect, useRef, useState } from 'react';
1012
import { Mic, Square, Play, Trash2 } from 'lucide-react';
@@ -14,20 +16,27 @@ import { cn } from '@/lib/utils';
1416
interface FeynmanRecorderProps {
1517
explanation: string;
1618
onExplanationChange: (v: string) => void;
19+
/** E2: 关联费曼笔记 id——录音以此持久化命名,跨会话回放 */
20+
noteId?: string | null;
1721
}
1822

1923
/** 单次录音时长上限:防止 PCM 累积无限增长(10 分钟 ≈ 19MB) */
2024
const MAX_RECORD_MS = 10 * 60 * 1000;
2125

22-
/** base64 → Blob(会话内回放用) */
26+
/** 录音文件 stem:feynman-{noteId}(uuid 安全字符,符合主进程 SAFE_NAME_RE) */
27+
function recordingStem(noteId: string): string {
28+
return `feynman-${noteId}`;
29+
}
30+
31+
/** base64 → Blob(回放用) */
2332
function base64ToBlob(base64: string, type: string): Blob {
2433
const binary = atob(base64);
2534
const bytes = new Uint8Array(binary.length);
2635
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
2736
return new Blob([bytes], { type });
2837
}
2938

30-
export function FeynmanRecorder({ explanation, onExplanationChange }: FeynmanRecorderProps) {
39+
export function FeynmanRecorder({ explanation, onExplanationChange, noteId }: FeynmanRecorderProps) {
3140
const [asrAvailable, setAsrAvailable] = useState(false);
3241
const [recording, setRecording] = useState(false);
3342
const [partialText, setPartialText] = useState('');
@@ -48,6 +57,11 @@ export function FeynmanRecorder({ explanation, onExplanationChange }: FeynmanRec
4857
const recordLimitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
4958
// ref 同步最新 playback URL:卸载闭包只捕获初始值,必须经 ref 才能正确 revoke
5059
const playbackUrlRef = useRef<string | null>(null);
60+
// ref 同步最新 noteId:stop 闭包与事件回调中读取,避免 useCallback 空依赖失效
61+
const noteIdRef = useRef(noteId);
62+
noteIdRef.current = noteId;
63+
// 最近一次持久化 Promise:删除前先等待完成,防止 save 晚于 delete 写回文件(孤儿残留)
64+
const savePromiseRef = useRef<Promise<unknown> | null>(null);
5165

5266
// 可用性探测:ASR 模型未就绪则隐藏入口
5367
useEffect(() => {
@@ -70,6 +84,18 @@ export function FeynmanRecorder({ explanation, onExplanationChange }: FeynmanRec
7084
setPlaybackUrl(next);
7185
}, []);
7286

87+
// E2 跨会话回放:挂载时加载该笔记已持久化的录音(不存在静默)
88+
useEffect(() => {
89+
const api = window.electronAPI;
90+
if (!api || !noteId) return;
91+
let cancelled = false;
92+
api.recording.load(recordingStem(noteId)).then((r) => {
93+
if (cancelled || !r?.success || !r.base64) return;
94+
updatePlaybackUrl(URL.createObjectURL(base64ToBlob(r.base64, 'audio/wav')));
95+
}).catch(() => { /* 加载失败静默——仅影响回放 */ });
96+
return () => { cancelled = true; };
97+
}, [noteId, updatePlaybackUrl]);
98+
7399
const stop = useCallback(async () => {
74100
if (!recordingRef.current) return;
75101
if (recordLimitTimerRef.current) {
@@ -90,7 +116,7 @@ export function FeynmanRecorder({ explanation, onExplanationChange }: FeynmanRec
90116
setRecording(false);
91117
setPartialText('');
92118

93-
// 会话内回放:累积的 PCM → WAV blob URL(不持久化)
119+
// 回放:累积的 PCM → WAV,持久化(有笔记关联)或仅会话内 blob URL
94120
const chunks = samplesRef.current;
95121
samplesRef.current = [];
96122
if (chunks.length > 0) {
@@ -101,6 +127,11 @@ export function FeynmanRecorder({ explanation, onExplanationChange }: FeynmanRec
101127
try {
102128
const wavBase64 = encodeWavBase64(merged, sampleRateRef.current, 1);
103129
updatePlaybackUrl(URL.createObjectURL(base64ToBlob(wavBase64, 'audio/wav')));
130+
// E2: 本地优先持久化({userData}/recordings/feynman-{noteId}.wav),跨会话可回放
131+
const stem = noteIdRef.current ? recordingStem(noteIdRef.current) : null;
132+
if (stem) {
133+
savePromiseRef.current = api?.recording.save(stem, wavBase64).catch(() => { /* 落盘失败静默——回放仍可用 */ });
134+
}
104135
} catch { /* 编码失败不影响转写文本 */ }
105136
}
106137
}, [cleanupIpc, updatePlaybackUrl]);
@@ -221,7 +252,19 @@ export function FeynmanRecorder({ explanation, onExplanationChange }: FeynmanRec
221252
<Play className="w-3.5 h-3.5" strokeWidth={1.5} />
222253
<audio src={playbackUrl} controls className="h-7 max-w-[200px]" />
223254
<button
224-
onClick={() => updatePlaybackUrl(null)}
255+
onClick={() => {
256+
// E2: 持久化录音删除本地文件,避免孤儿文件堆积。
257+
// 先等待在途 save 完成再删,防止 save 晚于 delete 写回(删除失效)
258+
const stem = noteIdRef.current ? recordingStem(noteIdRef.current) : null;
259+
const pending = savePromiseRef.current;
260+
savePromiseRef.current = null;
261+
if (stem) {
262+
void Promise.resolve(pending).catch(() => {}).then(() => {
263+
window.electronAPI?.recording.delete(stem).catch(() => {});
264+
});
265+
}
266+
updatePlaybackUrl(null);
267+
}}
225268
className="p-1 rounded hover:text-red-500 transition-colors"
226269
title="删除录音"
227270
>

‎client/src/features/feynman/components/FeynmanSteps.tsx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@ interface StepConceptProps {
2020
concept?: string;
2121
explanation: string;
2222
onExplanationChange: (v: string) => void;
23+
/** E2: 费曼笔记 id,供录音持久化关联(跨会话回放) */
24+
noteId?: string | null;
2325
}
2426

25-
export function StepConcept({ concept, explanation, onExplanationChange }: StepConceptProps) {
27+
export function StepConcept({ concept, explanation, onExplanationChange, noteId }: StepConceptProps) {
2628
const { container, item } = useFeynmanStagger();
2729
return (
2830
<motion.div
@@ -65,8 +67,8 @@ export function StepConcept({ concept, explanation, onExplanationChange }: StepC
6567
)}
6668
/>
6769
</div>
68-
{/* E2 口头讲解入口(ASR 不可用时自动隐藏) */}
69-
<FeynmanRecorder explanation={explanation} onExplanationChange={onExplanationChange} />
70+
{/* E2 口头讲解入口(ASR 不可用时自动隐藏;noteId 关联录音持久化) */}
71+
<FeynmanRecorder explanation={explanation} onExplanationChange={onExplanationChange} noteId={noteId} />
7072
</motion.div>
7173
<motion.div variants={item} className={cn(
7274
'p-kb-md rounded-kb-lg',

‎client/src/features/feynman/pages/FeynmanSessionPage.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ export default function FeynmanSessionPage() {
146146
concept={note?.concept}
147147
explanation={localExplanation}
148148
onExplanationChange={setLocalExplanation}
149+
noteId={noteId}
149150
/>
150151
</>
151152
)}

0 commit comments

Comments
 (0)