Skip to content

Commit 0c8669d

Browse files
committed
feat(live-class): implement live speech-to-text subtitles and toggle functionality
1 parent c7fd796 commit 0c8669d

3 files changed

Lines changed: 181 additions & 3 deletions

File tree

client/src/pages/LiveClassRoom.jsx

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
<>

server/app.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import path from "path";
55
import { fileURLToPath } from "url";
66
import { createServer } from "http";
77
import { Server } from "socket.io";
8+
import Anthropic from "@anthropic-ai/sdk";
89
import { initIO } from "./app/services/socketService.js";
910
import authRoutes from "./app/routes/auth.js";
1011
import courseRoutes from "./app/routes/courses.js";
@@ -156,6 +157,51 @@ export function buildApp() {
156157
io.to(to).emit("teacher-reanswer", { answer });
157158
});
158159

160+
// ── Speech-to-text subtitles ────────────────────────────────────────────
161+
// Interim results: relay instantly to all students (no AI processing)
162+
socket.on("speech:interim", ({ liveClassId, text }) => {
163+
if (!liveClassId || !text) return;
164+
socket.to(`liveclass:${liveClassId}`).emit("speech:subtitle", { text });
165+
});
166+
167+
// Final results: relay raw immediately, then ask Claude to clean up grammar
168+
socket.on("speech:final", async ({ liveClassId, text }) => {
169+
if (!liveClassId || !text) return;
170+
// Send raw final to students right away so there's no wait
171+
socket.to(`liveclass:${liveClassId}`).emit("speech:subtitle", { text });
172+
173+
// Ask Claude to fix grammar/punctuation and re-broadcast the polished version
174+
try {
175+
const anthropic = new Anthropic();
176+
const msg = await anthropic.messages.create({
177+
model: process.env.AI_MODEL || "claude-sonnet-4-6",
178+
max_tokens: 256,
179+
messages: [
180+
{
181+
role: "user",
182+
content:
183+
`Fix only grammar and punctuation in this live classroom speech transcript. ` +
184+
`Do NOT change the meaning or add/remove words. ` +
185+
`Output only the corrected text with no explanation:\n\n${text}`,
186+
},
187+
],
188+
});
189+
const corrected = msg.content[0]?.text?.trim();
190+
// Only re-emit if Claude actually changed something
191+
if (corrected && corrected !== text) {
192+
io.to(`liveclass:${liveClassId}`).emit("speech:subtitle", { text: corrected });
193+
}
194+
} catch (err) {
195+
console.error("Claude subtitle correction error:", err.message);
196+
}
197+
});
198+
199+
// Teacher stopped subtitles — clear subtitle bar for all students
200+
socket.on("speech:stop", ({ liveClassId }) => {
201+
if (!liveClassId) return;
202+
socket.to(`liveclass:${liveClassId}`).emit("speech:stop");
203+
});
204+
159205
socket.on("disconnect", () => {
160206
for (const [liveClassId, bSocketId] of broadcasters.entries()) {
161207
if (bSocketId === socket.id) {

server/tests/assignments.test.js

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,13 @@ describe("Assignments API", () => {
113113
// ── POST /api/assignments/:id/submit ──────────────────────────────────────
114114
describe("POST /api/assignments/:id/submit", () => {
115115
it("submits an assignment as an enrolled student", async () => {
116+
// Use a fresh single-assignment course so sequential lock has no predecessors
117+
const sc = await createTestCourse(request, teacherCookie, teacher.id, {
118+
title: "Submit Course",
119+
});
120+
await enrollStudent(request, sc.id, student.id);
116121
const created = await request
117-
.post(`/api/courses/${courseId}/assignments`)
122+
.post(`/api/courses/${sc.id}/assignments`)
118123
.set("Cookie", teacherCookie)
119124
.send({ title: "Submit Me", teacherId: teacher.id });
120125
const aId = created.body.id;
@@ -147,8 +152,13 @@ describe("Assignments API", () => {
147152
// ── GET /api/assignments/:id/submissions ──────────────────────────────────
148153
describe("GET /api/assignments/:id/submissions", () => {
149154
it("returns submissions for an assignment", async () => {
155+
// Fresh single-assignment course so the student can submit without sequential blocking
156+
const sc = await createTestCourse(request, teacherCookie, teacher.id, {
157+
title: "Subs Course",
158+
});
159+
await enrollStudent(request, sc.id, student.id);
150160
const created = await request
151-
.post(`/api/courses/${courseId}/assignments`)
161+
.post(`/api/courses/${sc.id}/assignments`)
152162
.set("Cookie", teacherCookie)
153163
.send({ title: "View Subs", teacherId: teacher.id });
154164
const aId = created.body.id;
@@ -168,8 +178,13 @@ describe("Assignments API", () => {
168178
// ── PATCH /api/assignments/submissions/:id/grade ──────────────────────────
169179
describe("PATCH /api/assignments/submissions/:submissionId/grade", () => {
170180
it("grades a submission as the teacher", async () => {
181+
// Fresh single-assignment course so the student can submit without sequential blocking
182+
const sc = await createTestCourse(request, teacherCookie, teacher.id, {
183+
title: "Grade Course",
184+
});
185+
await enrollStudent(request, sc.id, student.id);
171186
const created = await request
172-
.post(`/api/courses/${courseId}/assignments`)
187+
.post(`/api/courses/${sc.id}/assignments`)
173188
.set("Cookie", teacherCookie)
174189
.send({ title: "Grade Me", maxScore: 50, teacherId: teacher.id });
175190
const aId = created.body.id;

0 commit comments

Comments
 (0)