@@ -23,6 +23,7 @@ import type {
2323} from '@/lib/capture' ;
2424import { analyzeSession , analyzeVideo , analyzePartial , mergeNotes } from '@/lib/ai/sessionAnalyzer' ;
2525import type { AnalyzeResult } from '@/lib/ai/sessionAnalyzer' ;
26+ import { aiClient } from '@/lib/http/apiClient' ;
2627
2728interface IPCAudioStartResult {
2829 success : boolean ;
@@ -136,6 +137,7 @@ export function useClassroomCapture() {
136137 const pendingKeyframesRef = useRef < KeyFrame [ ] > ( [ ] ) ;
137138 const isPartialAnalyzingRef = useRef ( false ) ;
138139 const [ partialCount , setPartialCount ] = useState ( 0 ) ;
140+ const [ transcribedCount , setTranscribedCount ] = useState ( 0 ) ;
139141 const INCREMENTAL_BATCH_SIZE = 5 ;
140142
141143 useEffect ( ( ) => {
@@ -173,6 +175,46 @@ export function useClassroomCapture() {
173175 return ( ) => { offKeyframe ( ) ; offBundleReady ( ) ; } ;
174176 } , [ config . language ] ) ;
175177
178+ // ── Path B:流式 ASR — 语音段完成后立即转写 ──
179+ useEffect ( ( ) => {
180+ const offSegmentReady = captureEventBus . on < { sessionId : string ; segment : import ( '@/lib/capture' ) . AudioSegment } > (
181+ 'smart:audio_segment_ready' ,
182+ ( data ) => {
183+ const seg = data . segment ;
184+ // 先将音频段加入 bundle
185+ setSmartBundle ( ( prev ) => ( {
186+ ...prev ,
187+ audioSegments : [ ...( prev . audioSegments ?? [ ] ) , seg ] ,
188+ } ) ) ;
189+
190+ // 后台流式 ASR 转写(不阻塞采集)
191+ if ( ! seg . audioBase64 ) return ;
192+ const lang = config . language === 'en' ? 'en' : config . language === 'mixed' ? 'auto' : 'zh' ;
193+ aiClient . post < { text : string } > ( '/api/v1/asr/transcribe' , {
194+ audio_base64 : seg . audioBase64 ,
195+ language : lang ,
196+ sample_rate : 16000 ,
197+ channels : 1 ,
198+ } )
199+ . then ( ( resp ) => {
200+ const text = resp . text ?. trim ( ) || null ;
201+ // 将转写结果回填到对应的音频段
202+ setSmartBundle ( ( prev ) => ( {
203+ ...prev ,
204+ audioSegments : ( prev . audioSegments ?? [ ] ) . map ( ( s ) =>
205+ s . id === seg . id ? { ...s , audioText : text } : s ,
206+ ) ,
207+ } ) ) ;
208+ if ( text ) setTranscribedCount ( ( c ) => c + 1 ) ;
209+ } )
210+ . catch ( ( err ) => {
211+ console . warn ( '[useClassroomCapture] 流式 ASR 转写失败:' , err ) ;
212+ } ) ;
213+ } ,
214+ ) ;
215+ return ( ) => { offSegmentReady ( ) ; } ;
216+ } , [ config . language ] ) ;
217+
176218 // ── Path C:监听录制视频就绪 ──
177219 useEffect ( ( ) => {
178220 const offVideoReady = captureEventBus . on < { sessionId : string ; videoRecording : VideoRecording } > (
@@ -477,14 +519,22 @@ export function useClassroomCapture() {
477519 if ( partialNotesRef . current . length > 0 ) {
478520 setIsAnalyzing ( true ) ;
479521 setAnalysisError ( null ) ;
522+ const partials = [ ...partialNotesRef . current ] ;
480523 try {
481- const result = await mergeNotes ( partialNotesRef . current , {
524+ const result = await mergeNotes ( partials , {
482525 duration : ( smartBundle . duration ?? 0 ) / 1000 ,
483526 language : config . language ,
484527 } ) ;
485528 setAnalysisResult ( result ) ;
486- } catch ( err ) {
487- setAnalysisError ( err instanceof Error ? err . message : '笔记合并失败' ) ;
529+ } catch {
530+ // 降级:本地拼接片段笔记(无需 AI,零网络,避免全量重发)
531+ const fallbackContent = partials . join ( '\n\n---\n\n' ) ;
532+ setAnalysisResult ( {
533+ content : fallbackContent ,
534+ keyframesAnalyzed : smartBundle . keyframes ?. length ?? 0 ,
535+ modelUsed : 'local-concat' ,
536+ } ) ;
537+ toast ( { type : 'warning' , message : 'AI 合并不可用,已直接拼接片段笔记' } ) ;
488538 } finally {
489539 setIsAnalyzing ( false ) ;
490540 }
@@ -597,7 +647,7 @@ export function useClassroomCapture() {
597647 // 路径
598648 capturePath, setCapturePath, smartBundle,
599649 // 分析
600- isAnalyzing, analysisResult, analysisError, partialCount,
650+ isAnalyzing, analysisResult, analysisError, partialCount, transcribedCount ,
601651 // 录制
602652 recordingStatus, videoFilePath,
603653 // 操作
0 commit comments