@@ -189,6 +189,13 @@ export default function LiveClassRoom() {
189189 const recorderRef = useRef ( null ) ;
190190 const chunksRef = useRef ( [ ] ) ;
191191
192+ // ── subtitle / speech-to-text state ─────────────────────────────────────────
193+ const [ subtitleOn , setSubtitleOn ] = useState ( false ) ;
194+ const [ subtitle , setSubtitle ] = useState ( "" ) ;
195+ const subtitleOnRef = useRef ( false ) ; // ref so onend closure sees latest value
196+ const recognitionRef = useRef ( null ) ;
197+ const subtitleClearRef = useRef ( null ) ; // timeout handle to auto-clear final subtitles
198+
192199 // ─── data fetch ───────────────────────────────────────────────────────────────
193200 const loadClass = useCallback ( async ( ) => {
194201 const res = await apiFetch ( `/api/live-classes/${ id } ` ) ;
@@ -414,6 +421,71 @@ export default function LiveClassRoom() {
414421 } ;
415422 } , [ id , user . id ] ) ;
416423
424+ // ─── teacher: live speech-to-text subtitles ──────────────────────────────
425+ const toggleSubtitles = useCallback ( ( ) => {
426+ if ( subtitleOnRef . current ) {
427+ recognitionRef . current ?. stop ( ) ;
428+ recognitionRef . current = null ;
429+ subtitleOnRef . current = false ;
430+ setSubtitleOn ( false ) ;
431+ setSubtitle ( "" ) ;
432+ socket . emit ( "speech:stop" , { liveClassId : id } ) ;
433+ } else {
434+ const SR = window . SpeechRecognition || window . webkitSpeechRecognition ;
435+ if ( ! SR ) {
436+ alert (
437+ "Speech recognition is not supported in this browser. Please use Chrome or Edge." ,
438+ ) ;
439+ return ;
440+ }
441+ const r = new SR ( ) ;
442+ r . continuous = true ;
443+ r . interimResults = true ;
444+ r . lang = "en-US" ;
445+
446+ r . onresult = ( e ) => {
447+ let interim = "" ;
448+ let final = "" ;
449+ for ( let i = e . resultIndex ; i < e . results . length ; i ++ ) {
450+ const t = e . results [ i ] [ 0 ] . transcript ;
451+ if ( e . results [ i ] . isFinal ) final += t ;
452+ else interim += t ;
453+ }
454+ if ( interim ) {
455+ setSubtitle ( interim ) ;
456+ socket . emit ( "speech:interim" , { liveClassId : id , text : interim } ) ;
457+ }
458+ if ( final ) {
459+ setSubtitle ( final ) ;
460+ socket . emit ( "speech:final" , { liveClassId : id , text : final } ) ;
461+ clearTimeout ( subtitleClearRef . current ) ;
462+ subtitleClearRef . current = setTimeout ( ( ) => setSubtitle ( "" ) , 6000 ) ;
463+ }
464+ } ;
465+
466+ r . onerror = ( e ) => {
467+ if ( e . error !== "aborted" )
468+ console . error ( "Speech recognition error:" , e . error ) ;
469+ } ;
470+
471+ r . onend = ( ) => {
472+ // Auto-restart while CC is still toggled on (browser stops after silence)
473+ if ( subtitleOnRef . current ) {
474+ try {
475+ r . start ( ) ;
476+ } catch {
477+ /* ignore if already starting */
478+ }
479+ }
480+ } ;
481+
482+ recognitionRef . current = r ;
483+ subtitleOnRef . current = true ;
484+ setSubtitleOn ( true ) ;
485+ r . start ( ) ;
486+ }
487+ } , [ id , socket ] ) ; // subtitleOnRef / recognitionRef are refs — no dep needed
488+
417489 // ─── teacher: end class ───────────────────────────────────────────────────
418490 const endClass = useCallback ( async ( ) => {
419491 socket . emit ( "end-class" , { liveClassId : id } ) ;
@@ -781,6 +853,18 @@ export default function LiveClassRoom() {
781853 const onRecordingAvailable = ( { recordingUrl } ) =>
782854 setLiveClass ( ( p ) => p && { ...p , recordingUrl } ) ;
783855
856+ // ── speech subtitles ──────────────────────────────────────────────────
857+ // Students (and teacher for Claude-corrected version) receive subtitle events
858+ const onSubtitle = ( { text } ) => {
859+ setSubtitle ( text ) ;
860+ clearTimeout ( subtitleClearRef . current ) ;
861+ subtitleClearRef . current = setTimeout ( ( ) => setSubtitle ( "" ) , 6000 ) ;
862+ } ;
863+ const onSubtitleStop = ( ) => {
864+ clearTimeout ( subtitleClearRef . current ) ;
865+ setSubtitle ( "" ) ;
866+ } ;
867+
784868 socket . on ( "new-comment" , onNewComment ) ;
785869 socket . on ( "new-reply" , onNewReply ) ;
786870 socket . on ( "new-question" , onNewQuestion ) ;
@@ -802,6 +886,8 @@ export default function LiveClassRoom() {
802886 socket . on ( "screen-share-started" , onScreenStarted ) ;
803887 socket . on ( "screen-share-stopped" , onScreenStopped ) ;
804888 socket . on ( "recording-available" , onRecordingAvailable ) ;
889+ socket . on ( "speech:subtitle" , onSubtitle ) ;
890+ socket . on ( "speech:stop" , onSubtitleStop ) ;
805891
806892 return ( ) => {
807893 socket . emit ( "leave-liveclass" , id ) ;
@@ -810,11 +896,19 @@ export default function LiveClassRoom() {
810896 screenStreamRef . current ?. getTracks ( ) . forEach ( ( t ) => t . stop ( ) ) ;
811897 peerConnsRef . current . forEach ( ( pc ) => pc . close ( ) ) ;
812898 socket . emit ( "broadcaster-stop" , { liveClassId : id } ) ;
899+ // Stop speech recognition if active
900+ if ( subtitleOnRef . current ) {
901+ recognitionRef . current ?. stop ( ) ;
902+ recognitionRef . current = null ;
903+ subtitleOnRef . current = false ;
904+ socket . emit ( "speech:stop" , { liveClassId : id } ) ;
905+ }
813906 } else {
814907 studentMicStreamRef . current ?. getTracks ( ) . forEach ( ( t ) => t . stop ( ) ) ;
815908 studentCamStreamRef . current ?. getTracks ( ) . forEach ( ( t ) => t . stop ( ) ) ;
816909 peerConnRef . current ?. close ( ) ;
817910 }
911+ clearTimeout ( subtitleClearRef . current ) ;
818912 [
819913 [ "new-comment" , onNewComment ] ,
820914 [ "new-reply" , onNewReply ] ,
@@ -837,6 +931,8 @@ export default function LiveClassRoom() {
837931 [ "screen-share-started" , onScreenStarted ] ,
838932 [ "screen-share-stopped" , onScreenStopped ] ,
839933 [ "recording-available" , onRecordingAvailable ] ,
934+ [ "speech:subtitle" , onSubtitle ] ,
935+ [ "speech:stop" , onSubtitleStop ] ,
840936 ] . forEach ( ( [ ev , fn ] ) => socket . off ( ev , fn ) ) ;
841937 } ;
842938 } , [ id , isTeacher ] ) ; // eslint-disable-line react-hooks/exhaustive-deps
@@ -1105,6 +1201,17 @@ export default function LiveClassRoom() {
11051201 ) }
11061202 </ div >
11071203 ) }
1204+
1205+ { /* ── Live subtitle overlay ──────────────────────────────────── */ }
1206+ { subtitle && (
1207+ < div className = "absolute bottom-4 left-1/2 -translate-x-1/2 z-40 w-full max-w-2xl px-6 pointer-events-none" >
1208+ < div className = "text-center px-5 py-2.5 rounded-2xl bg-black/80 backdrop-blur-sm border border-white/10 shadow-xl" >
1209+ < p className = "text-white text-[15px] font-medium leading-snug tracking-wide" >
1210+ { subtitle }
1211+ </ p >
1212+ </ div >
1213+ </ div >
1214+ ) }
11081215 </ div >
11091216
11101217 { /* ── Participant strip ───────────────────────────────────────────── */ }
@@ -1273,6 +1380,16 @@ export default function LiveClassRoom() {
12731380 Uploading…
12741381 </ span >
12751382 ) }
1383+ { cameraStreamRef . current && (
1384+ < CtrlBtn
1385+ onClick = { toggleSubtitles }
1386+ active = { subtitleOn }
1387+ label = { subtitleOn ? "CC On" : "Captions" }
1388+ title = "Live subtitles via speech recognition"
1389+ >
1390+ CC
1391+ </ CtrlBtn >
1392+ ) }
12761393 </ >
12771394 ) : (
12781395 < >
0 commit comments