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 */
911import { useCallback , useEffect , useRef , useState } from 'react' ;
1012import { Mic , Square , Play , Trash2 } from 'lucide-react' ;
@@ -14,20 +16,27 @@ import { cn } from '@/lib/utils';
1416interface FeynmanRecorderProps {
1517 explanation : string ;
1618 onExplanationChange : ( v : string ) => void ;
19+ /** E2: 关联费曼笔记 id——录音以此持久化命名,跨会话回放 */
20+ noteId ?: string | null ;
1721}
1822
1923/** 单次录音时长上限:防止 PCM 累积无限增长(10 分钟 ≈ 19MB) */
2024const 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(回放用) */
2332function 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 >
0 commit comments