@@ -16,6 +16,9 @@ interface FeynmanRecorderProps {
1616 onExplanationChange : ( v : string ) => void ;
1717}
1818
19+ /** 单次录音时长上限:防止 PCM 累积无限增长(10 分钟 ≈ 19MB) */
20+ const MAX_RECORD_MS = 10 * 60 * 1000 ;
21+
1922/** base64 → Blob(会话内回放用) */
2023function base64ToBlob ( base64 : string , type : string ) : Blob {
2124 const binary = atob ( base64 ) ;
@@ -38,9 +41,13 @@ export function FeynmanRecorder({ explanation, onExplanationChange }: FeynmanRec
3841 onChangeRef . current = onExplanationChange ;
3942
4043 const recordingRef = useRef ( false ) ;
44+ const startingRef = useRef ( false ) ;
4145 const samplesRef = useRef < Float32Array [ ] > ( [ ] ) ;
4246 const sampleRateRef = useRef ( 16000 ) ;
4347 const unsubsRef = useRef < Array < ( ) => void > > ( [ ] ) ;
48+ const recordLimitTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
49+ // ref 同步最新 playback URL:卸载闭包只捕获初始值,必须经 ref 才能正确 revoke
50+ const playbackUrlRef = useRef < string | null > ( null ) ;
4451
4552 // 可用性探测:ASR 模型未就绪则隐藏入口
4653 useEffect ( ( ) => {
@@ -56,13 +63,28 @@ export function FeynmanRecorder({ explanation, onExplanationChange }: FeynmanRec
5663 unsubsRef . current = [ ] ;
5764 } , [ ] ) ;
5865
66+ /** 更新回放 URL 并回收旧的(ref 同步,保证卸载时可回收) */
67+ const updatePlaybackUrl = useCallback ( ( next : string | null ) => {
68+ if ( playbackUrlRef . current ) URL . revokeObjectURL ( playbackUrlRef . current ) ;
69+ playbackUrlRef . current = next ;
70+ setPlaybackUrl ( next ) ;
71+ } , [ ] ) ;
72+
5973 const stop = useCallback ( async ( ) => {
6074 if ( ! recordingRef . current ) return ;
61- recordingRef . current = false ;
62- cleanupIpc ( ) ;
75+ if ( recordLimitTimerRef . current ) {
76+ clearTimeout ( recordLimitTimerRef . current ) ;
77+ recordLimitTimerRef . current = null ;
78+ }
6379 const api = window . electronAPI ;
6480 try {
81+ // 先停 ASR:主进程会 flush 最后一段 final,此时订阅仍在且
82+ // recordingRef 仍为 true,最后一句话能正常追加进讲解文本
6583 await api ?. invoke ( 'local_asr_stream_stop' ) ;
84+ } catch { /* 静默降级 */ }
85+ recordingRef . current = false ;
86+ cleanupIpc ( ) ;
87+ try {
6688 await api ?. invoke ( 'audio_capture_stop' ) ;
6789 } catch { /* 静默降级 */ }
6890 setRecording ( false ) ;
@@ -78,18 +100,18 @@ export function FeynmanRecorder({ explanation, onExplanationChange }: FeynmanRec
78100 for ( const c of chunks ) { merged . set ( c , offset ) ; offset += c . length ; }
79101 try {
80102 const wavBase64 = encodeWavBase64 ( merged , sampleRateRef . current , 1 ) ;
81- setPlaybackUrl ( ( prev ) => {
82- if ( prev ) URL . revokeObjectURL ( prev ) ;
83- return URL . createObjectURL ( base64ToBlob ( wavBase64 , 'audio/wav' ) ) ;
84- } ) ;
103+ updatePlaybackUrl ( URL . createObjectURL ( base64ToBlob ( wavBase64 , 'audio/wav' ) ) ) ;
85104 } catch { /* 编码失败不影响转写文本 */ }
86105 }
87- } , [ cleanupIpc ] ) ;
106+ } , [ cleanupIpc , updatePlaybackUrl ] ) ;
88107
89108 const start = useCallback ( async ( ) => {
90109 const api = window . electronAPI ;
91- if ( ! api || recordingRef . current ) return ;
110+ // startingRef 同步上锁:防止异步启动期间双击重复启动(监听器泄漏/文本重复追加)
111+ if ( ! api || recordingRef . current || startingRef . current ) return ;
112+ startingRef . current = true ;
92113 setError ( null ) ;
114+ let captureStarted = false ;
93115 try {
94116 const status = await api . invoke ( 'audio_capture_status' ) as { active : boolean } ;
95117 if ( status ?. active ) {
@@ -103,15 +125,20 @@ export function FeynmanRecorder({ explanation, onExplanationChange }: FeynmanRec
103125 setError ( capture ?. error || '麦克风启动失败,请检查设备与权限' ) ;
104126 return ;
105127 }
128+ captureStarted = true ;
106129 const asrStart = await api . invoke ( 'local_asr_stream_start' , { sampleRate : 16000 } ) as { success : boolean } ;
107130 if ( ! asrStart ?. success ) {
108- await api . invoke ( 'audio_capture_stop' ) ;
131+ await api . invoke ( 'audio_capture_stop' ) . catch ( ( ) => { } ) ;
109132 setError ( '语音识别启动失败,请重试' ) ;
110133 return ;
111134 }
112135 } catch {
136+ // 异常若发生在本组件已启动采集之后,必须回收麦克风,避免残留占用阻塞其他音频功能
137+ if ( captureStarted ) api . invoke ( 'audio_capture_stop' ) . catch ( ( ) => { } ) ;
113138 setError ( '录音启动失败,请重试' ) ;
114139 return ;
140+ } finally {
141+ startingRef . current = false ;
115142 }
116143
117144 samplesRef . current = [ ] ;
@@ -141,13 +168,19 @@ export function FeynmanRecorder({ explanation, onExplanationChange }: FeynmanRec
141168
142169 recordingRef . current = true ;
143170 setRecording ( true ) ;
144- } , [ ] ) ;
171+ // 时长上限自动停止:避免长时间录音 PCM 内存无限增长
172+ recordLimitTimerRef . current = setTimeout ( ( ) => { void stop ( ) ; } , MAX_RECORD_MS ) ;
173+ } , [ stop ] ) ;
145174
146175 // 卸载兜底:释放麦克风与 ASR 流、回收 blob URL
147176 useEffect ( ( ) => {
148177 return ( ) => {
149- if ( recordingRef . current ) {
178+ if ( recordLimitTimerRef . current ) clearTimeout ( recordLimitTimerRef . current ) ;
179+ // startingRef 覆盖"启动中途卸载"竞态:capture 已在主进程启动但
180+ // recordingRef 尚未置 true,此时也必须释放,否则麦克风残留占用
181+ if ( recordingRef . current || startingRef . current ) {
150182 recordingRef . current = false ;
183+ startingRef . current = false ;
151184 cleanupIpc ( ) ;
152185 const api = window . electronAPI ;
153186 api ?. invoke ( 'local_asr_stream_stop' ) . catch ( ( ) => { } ) ;
@@ -156,8 +189,7 @@ export function FeynmanRecorder({ explanation, onExplanationChange }: FeynmanRec
156189 } ;
157190 } , [ cleanupIpc ] ) ;
158191 useEffect ( ( ) => {
159- return ( ) => { if ( playbackUrl ) URL . revokeObjectURL ( playbackUrl ) ; } ;
160- // eslint-disable-next-line react-hooks/exhaustive-deps
192+ return ( ) => { if ( playbackUrlRef . current ) URL . revokeObjectURL ( playbackUrlRef . current ) ; } ;
161193 } , [ ] ) ;
162194
163195 // 可选增强:ASR 不可用时隐藏入口
@@ -189,7 +221,7 @@ export function FeynmanRecorder({ explanation, onExplanationChange }: FeynmanRec
189221 < Play className = "w-3.5 h-3.5" strokeWidth = { 1.5 } />
190222 < audio src = { playbackUrl } controls className = "h-7 max-w-[200px]" />
191223 < button
192- onClick = { ( ) => { URL . revokeObjectURL ( playbackUrl ) ; setPlaybackUrl ( null ) ; } }
224+ onClick = { ( ) => updatePlaybackUrl ( null ) }
193225 className = "p-1 rounded hover:text-red-500 transition-colors"
194226 title = "删除录音"
195227 >
0 commit comments