Add near-live transcription, recording, and markers

Groq-powered transcription plus session recording with time-aligned markers,
all anchored on the broadcaster's source audio.

- server: /transcribe proxies WAV windows to Groq (whisper-large-v3-turbo) with
  the key server-side, and fans transcript text to listeners over WS. Recordings
  API stores audio + a markers/transcript JSON sidecar on local disk
  (/recordings upload, list, range-served audio, markers).
- web capture lib: one AudioContext yields the level-meter analyser and 16 kHz
  mono WAV windows; MediaRecorder mime/ext picker.
- Broadcaster: live transcript panel, manual "+ Marker" button, auto event
  marker, full-session recording with a Save/Discard card that uploads audio +
  markers + transcript.
- Listener: live captions panel from transcript events.
- Recordings library (/recordings): list sessions, play audio, click any marker
  or transcript line to seek.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-04 21:16:52 -05:00
co-authored by Claude Opus 4.8
parent 5c34634e63
commit e00d844f06
8 changed files with 930 additions and 143 deletions
+2
View File
@@ -3,3 +3,5 @@ dist/
.env .env
*.log *.log
.DS_Store .DS_Store
recordings/
server/recordings/
+11
View File
@@ -32,3 +32,14 @@ TURN_TLS_PORT=443
# Optional: serve the built SPA from this directory in a single process (no # Optional: serve the built SPA from this directory in a single process (no
# separate web server). Point it at web/dist. Leave blank in dev (Vite serves). # separate web server). Point it at web/dist. Leave blank in dev (Vite serves).
STATIC_DIR= STATIC_DIR=
# Groq transcription. The key stays server-side; /transcribe proxies to Groq.
# Leave GROQ_API_KEY blank to disable transcription (the /transcribe route then
# returns 503 and the UI hides the transcript panel).
GROQ_API_KEY=
GROQ_MODEL=whisper-large-v3-turbo
# Optional ISO language hint (e.g. en) to improve accuracy and speed.
GROQ_LANGUAGE=
# Where saved recordings (audio + markers JSON) are written.
RECORDINGS_DIR=./recordings
+276 -1
View File
@@ -35,6 +35,17 @@ const TURN_TLS_PORT = process.env.TURN_TLS_PORT || "443";
// deployment behind a reverse proxy). Unset in dev, where Vite serves the SPA. // deployment behind a reverse proxy). Unset in dev, where Vite serves the SPA.
const STATIC_DIR = process.env.STATIC_DIR || ""; const STATIC_DIR = process.env.STATIC_DIR || "";
// Groq transcription (OpenAI-compatible audio endpoint). The key stays here and
// never reaches the browser; the broadcaster posts audio windows to /transcribe.
const GROQ_API_KEY = process.env.GROQ_API_KEY || "";
const GROQ_MODEL = process.env.GROQ_MODEL || "whisper-large-v3-turbo";
const GROQ_LANGUAGE = process.env.GROQ_LANGUAGE || ""; // optional ISO code, e.g. "en"
const GROQ_URL = "https://api.groq.com/openai/v1/audio/transcriptions";
// Where saved recordings (audio + markers JSON sidecar) live.
const RECORDINGS_DIR = process.env.RECORDINGS_DIR || path.join(__dirname, "recordings");
const MAX_UPLOAD_BYTES = parseInt(process.env.MAX_UPLOAD_BYTES || "209715200", 10); // 200 MB
const startedAt = Date.now(); const startedAt = Date.now();
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -57,6 +68,12 @@ function broadcastToListeners(obj) {
} }
} }
// Transcript goes to every listener (live captions) and back to the broadcaster.
function broadcastTranscript(obj) {
broadcastToListeners(obj);
send(broadcaster, obj);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Ephemeral TURN credentials (PRD sections 6.4, 6.5). // Ephemeral TURN credentials (PRD sections 6.4, 6.5).
// coturn use-auth-secret / REST mechanism: // coturn use-auth-secret / REST mechanism:
@@ -102,11 +119,13 @@ function buildIceServers() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// HTTP server: /health and /ice. Everything else 404s. // HTTP server: /health and /ice. Everything else 404s.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const httpServer = http.createServer((req, res) => { const httpServer = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`); const url = new URL(req.url, `http://${req.headers.host}`);
// Permissive CORS: /ice creds are public and short-lived; /health is status. // Permissive CORS: /ice creds are public and short-lived; /health is status.
res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
if (req.method === "OPTIONS") { if (req.method === "OPTIONS") {
res.writeHead(204); res.writeHead(204);
res.end(); res.end();
@@ -118,6 +137,7 @@ const httpServer = http.createServer((req, res) => {
status: "ok", status: "ok",
broadcasterOnline: broadcaster !== null, broadcasterOnline: broadcaster !== null,
listeners: listeners.size, listeners: listeners.size,
transcription: GROQ_API_KEY ? "enabled" : "disabled",
uptimeSeconds: Math.floor((Date.now() - startedAt) / 1000), uptimeSeconds: Math.floor((Date.now() - startedAt) / 1000),
}); });
return; return;
@@ -129,6 +149,31 @@ const httpServer = http.createServer((req, res) => {
return; return;
} }
if (url.pathname === "/transcribe" && req.method === "POST") {
await handleTranscribe(req, res, url);
return;
}
// Recordings: upload audio, attach markers, list, fetch audio and markers.
if (url.pathname === "/recordings" && req.method === "POST") {
await handleRecordingUpload(req, res, url);
return;
}
if (url.pathname === "/recordings" && req.method === "GET") {
await handleRecordingList(res);
return;
}
const recMatch = url.pathname.match(/^\/recordings\/([A-Za-z0-9_-]+)(\/markers)?$/);
if (recMatch) {
const id = recMatch[1];
if (recMatch[2]) {
if (req.method === "POST") return void (await handleMarkersUpload(req, res, id));
if (req.method === "GET") return void (await handleMarkersGet(res, id));
} else if (req.method === "GET") {
return void (await handleRecordingGet(req, res, id));
}
}
// Serve the built SPA when STATIC_DIR is configured (production single // Serve the built SPA when STATIC_DIR is configured (production single
// process). Otherwise everything else is a 404. // process). Otherwise everything else is a 404.
if (STATIC_DIR && (req.method === "GET" || req.method === "HEAD")) { if (STATIC_DIR && (req.method === "GET" || req.method === "HEAD")) {
@@ -206,6 +251,232 @@ function serveStatic(req, res, pathname) {
}); });
} }
// ---------------------------------------------------------------------------
// Transcription (Groq) and recordings.
// ---------------------------------------------------------------------------
const AUDIO_MIME = {
webm: "audio/webm",
ogg: "audio/ogg",
mp4: "audio/mp4",
m4a: "audio/mp4",
wav: "audio/wav",
mp3: "audio/mpeg",
};
function readBody(req, maxBytes = MAX_UPLOAD_BYTES) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
req.on("data", (c) => {
size += c.length;
if (size > maxBytes) {
reject(new Error("too-large"));
req.destroy();
return;
}
chunks.push(c);
});
req.on("end", () => resolve(Buffer.concat(chunks)));
req.on("error", reject);
});
}
// Forward an audio buffer to Groq's Whisper endpoint and return the text.
async function transcribeWithGroq(buffer, filename, mime) {
const form = new FormData();
form.append("file", new Blob([buffer], { type: mime }), filename);
form.append("model", GROQ_MODEL);
form.append("response_format", "json");
form.append("temperature", "0");
if (GROQ_LANGUAGE) form.append("language", GROQ_LANGUAGE);
const resp = await fetch(GROQ_URL, {
method: "POST",
headers: { Authorization: `Bearer ${GROQ_API_KEY}` },
body: form,
});
if (!resp.ok) {
const t = await resp.text().catch(() => "");
throw new Error(`groq ${resp.status}: ${t.slice(0, 200)}`);
}
const data = await resp.json();
return (data.text || "").trim();
}
async function handleTranscribe(req, res, url) {
if (!GROQ_API_KEY) {
sendJson(res, 503, { error: "transcription-disabled" });
return;
}
let body;
try {
body = await readBody(req);
} catch {
sendJson(res, 413, { error: "too-large" });
return;
}
if (!body.length) {
sendJson(res, 400, { error: "empty" });
return;
}
const offsetMs = parseInt(url.searchParams.get("offset") || "0", 10) || 0;
const mime = req.headers["content-type"] || "audio/wav";
const ext = mime.includes("webm") ? "webm" : mime.includes("mp4") || mime.includes("m4a") ? "m4a" : "wav";
try {
const text = await transcribeWithGroq(body, `chunk.${ext}`, mime);
if (text) broadcastTranscript({ type: "transcript", offsetMs, text, ts: Date.now() });
sendJson(res, 200, { text, offsetMs });
} catch (e) {
console.warn("[transcribe]", e.message);
sendJson(res, 502, { error: "transcription-failed" });
}
}
function ensureRecordingsDir() {
fs.mkdirSync(RECORDINGS_DIR, { recursive: true });
}
function newRecordingId() {
return `${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
}
async function handleRecordingUpload(req, res, url) {
let body;
try {
body = await readBody(req);
} catch {
sendJson(res, 413, { error: "too-large" });
return;
}
if (!body.length) {
sendJson(res, 400, { error: "empty" });
return;
}
let ext = (url.searchParams.get("ext") || "webm").toLowerCase().replace(/[^a-z0-9]/g, "");
if (!AUDIO_MIME[ext]) ext = "webm";
const id = newRecordingId();
ensureRecordingsDir();
fs.writeFileSync(path.join(RECORDINGS_DIR, `${id}.${ext}`), body);
const meta = {
id,
ext,
createdAt: new Date().toISOString(),
startedAt: url.searchParams.get("startedAt") || null,
durationMs: parseInt(url.searchParams.get("durationMs") || "0", 10) || null,
sizeBytes: body.length,
markers: [],
transcript: [],
};
fs.writeFileSync(path.join(RECORDINGS_DIR, `${id}.json`), JSON.stringify(meta, null, 2));
sendJson(res, 200, { id });
}
async function handleMarkersUpload(req, res, id) {
const metaPath = path.join(RECORDINGS_DIR, `${id}.json`);
if (!fs.existsSync(metaPath)) {
sendJson(res, 404, { error: "not-found" });
return;
}
let body;
try {
body = await readBody(req, 8_000_000);
} catch {
sendJson(res, 413, { error: "too-large" });
return;
}
let incoming;
try {
incoming = JSON.parse(body.toString("utf8"));
} catch {
sendJson(res, 400, { error: "bad-json" });
return;
}
const meta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
if (Array.isArray(incoming.markers)) meta.markers = incoming.markers;
if (Array.isArray(incoming.transcript)) meta.transcript = incoming.transcript;
if (incoming.durationMs) meta.durationMs = incoming.durationMs;
if (incoming.title) meta.title = String(incoming.title).slice(0, 200);
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
sendJson(res, 200, { ok: true, id });
}
async function handleRecordingList(res) {
ensureRecordingsDir();
const items = fs
.readdirSync(RECORDINGS_DIR)
.filter((f) => f.endsWith(".json"))
.map((f) => {
try {
const m = JSON.parse(fs.readFileSync(path.join(RECORDINGS_DIR, f), "utf8"));
return {
id: m.id,
title: m.title || null,
createdAt: m.createdAt,
durationMs: m.durationMs,
sizeBytes: m.sizeBytes,
ext: m.ext,
markerCount: (m.markers || []).length,
transcriptCount: (m.transcript || []).length,
};
} catch {
return null;
}
})
.filter(Boolean)
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
sendJson(res, 200, { recordings: items });
}
async function handleMarkersGet(res, id) {
const metaPath = path.join(RECORDINGS_DIR, `${id}.json`);
if (!fs.existsSync(metaPath)) {
sendJson(res, 404, { error: "not-found" });
return;
}
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
res.end(fs.readFileSync(metaPath));
}
// Stream the recorded audio with HTTP range support so the review player can seek.
async function handleRecordingGet(req, res, id) {
const metaPath = path.join(RECORDINGS_DIR, `${id}.json`);
if (!fs.existsSync(metaPath)) {
sendJson(res, 404, { error: "not-found" });
return;
}
const meta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
const audioPath = path.join(RECORDINGS_DIR, `${id}.${meta.ext}`);
if (!fs.existsSync(audioPath)) {
sendJson(res, 404, { error: "no-audio" });
return;
}
const stat = fs.statSync(audioPath);
const type = AUDIO_MIME[meta.ext] || "application/octet-stream";
const range = req.headers.range;
if (range) {
const m = /bytes=(\d*)-(\d*)/.exec(range);
let start = m && m[1] ? parseInt(m[1], 10) : 0;
let end = m && m[2] ? parseInt(m[2], 10) : stat.size - 1;
if (isNaN(start) || start < 0) start = 0;
if (isNaN(end) || end >= stat.size) end = stat.size - 1;
if (start > end) {
res.writeHead(416, { "Content-Range": `bytes */${stat.size}` });
res.end();
return;
}
res.writeHead(206, {
"Content-Type": type,
"Content-Range": `bytes ${start}-${end}/${stat.size}`,
"Accept-Ranges": "bytes",
"Content-Length": end - start + 1,
});
fs.createReadStream(audioPath, { start, end }).pipe(res);
} else {
res.writeHead(200, { "Content-Type": type, "Content-Length": stat.size, "Accept-Ranges": "bytes" });
fs.createReadStream(audioPath).pipe(res);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// WebSocket signaling on path /ws. // WebSocket signaling on path /ws.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -345,6 +616,10 @@ httpServer.listen(PORT, () => {
} else { } else {
console.log(`[livecast] TURN enabled for realm ${TURN_REALM} (ttl ${TURN_TTL_SECONDS}s)`); console.log(`[livecast] TURN enabled for realm ${TURN_REALM} (ttl ${TURN_TTL_SECONDS}s)`);
} }
console.log(
`[livecast] transcription ${GROQ_API_KEY ? `enabled (${GROQ_MODEL})` : "disabled (set GROQ_API_KEY)"}; ` +
`recordings dir ${RECORDINGS_DIR}`
);
}); });
function loadEnvFile(file) { function loadEnvFile(file) {
+294 -138
View File
@@ -1,78 +1,87 @@
import { useEffect, useRef, useState, useCallback } from "react"; import { useEffect, useRef, useState, useCallback } from "react";
import { Link } from "react-router-dom";
import { Signaling, fetchIceServers } from "../lib/signaling.js"; import { Signaling, fetchIceServers } from "../lib/signaling.js";
import { startMicCapture, pickRecordingMime, fmtClock } from "../lib/capture.js";
import { Button } from "./ui/button.jsx"; import { Button } from "./ui/button.jsx";
import { Card, CardContent } from "./ui/card.jsx"; import { Card, CardContent } from "./ui/card.jsx";
import { cn } from "../lib/utils.js"; import { cn } from "../lib/utils.js";
// Broadcaster page (PRD 4.1). Captures the mic and creates one peer connection const TRANSCRIBE_INTERVAL_MS = 5000;
// per listener, attaching the local audio track to each.
// Broadcaster page (PRD 4.1) plus near-live transcription, session recording,
// and markers. One mic stream feeds the listeners (WebRTC), the level meter,
// the transcription windows, and the full-session recorder.
export default function Broadcaster() { export default function Broadcaster() {
const [status, setStatus] = useState("idle"); // idle | starting | live | error const [status, setStatus] = useState("idle"); // idle | starting | live | error
const [error, setError] = useState(""); const [error, setError] = useState("");
const [listenerCount, setListenerCount] = useState(0); const [listenerCount, setListenerCount] = useState(0);
const [micLevel, setMicLevel] = useState(0); const [micLevel, setMicLevel] = useState(0);
const [transcript, setTranscript] = useState([]); // {offsetMs, text}
const [markers, setMarkers] = useState([]); // {offsetMs, type, label}
const [transcribeOn, setTranscribeOn] = useState(true);
const [pending, setPending] = useState(null); // {blob, ext, durationMs}
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(null); // {id}
const [title, setTitle] = useState("");
const sig = useRef(null); const sig = useRef(null);
const stream = useRef(null); const stream = useRef(null);
const peers = useRef(new Map()); // listenerId -> { pc, pending: [] } const peers = useRef(new Map());
const ice = useRef(null); const ice = useRef(null);
const audioCtx = useRef(null); const capture = useRef(null); // mic capture controller
const raf = useRef(null); const raf = useRef(null);
const recorder = useRef(null);
const recChunks = useRef([]);
const recMime = useRef(null);
const recStartMs = useRef(0);
const markerSeq = useRef(0);
const updateCount = useCallback(() => setListenerCount(peers.current.size), []); const updateCount = useCallback(() => setListenerCount(peers.current.size), []);
const teardown = useCallback(() => { // Send a captured WAV window to the server for transcription.
if (raf.current) cancelAnimationFrame(raf.current); const postChunk = useCallback(async (wavBlob, offsetMs) => {
raf.current = null;
for (const { pc } of peers.current.values()) {
try { try {
pc.close(); const res = await fetch(`/transcribe?offset=${offsetMs}`, {
method: "POST",
headers: { "Content-Type": "audio/wav" },
body: wavBlob,
});
if (res.status === 503) {
setTranscribeOn(false);
return;
}
if (!res.ok) return;
const data = await res.json();
if (data.text) {
setTranscript((t) => [...t, { offsetMs, text: data.text }]);
}
} catch { } catch {
// ignore // transient network error; the next window will retry
} }
}
peers.current.clear();
if (stream.current) {
stream.current.getTracks().forEach((t) => t.stop());
stream.current = null;
}
if (audioCtx.current) {
audioCtx.current.close().catch(() => {});
audioCtx.current = null;
}
if (sig.current) {
sig.current.close();
sig.current = null;
}
setListenerCount(0);
setMicLevel(0);
}, []); }, []);
const addMarker = useCallback(() => {
if (status !== "live") return;
const offsetMs = Date.now() - recStartMs.current;
markerSeq.current += 1;
setMarkers((m) => [...m, { offsetMs, type: "manual", label: `Marker ${markerSeq.current}` }]);
}, [status]);
const createPeerFor = useCallback(async (listenerId) => { const createPeerFor = useCallback(async (listenerId) => {
const pc = new RTCPeerConnection(ice.current); const pc = new RTCPeerConnection(ice.current);
const record = { pc, pending: [] }; const record = { pc, pending: [] };
peers.current.set(listenerId, record); peers.current.set(listenerId, record);
updateCount(); updateCount();
for (const track of stream.current.getTracks()) { for (const track of stream.current.getTracks()) pc.addTrack(track, stream.current);
pc.addTrack(track, stream.current);
}
pc.onicecandidate = (e) => { pc.onicecandidate = (e) => {
if (e.candidate) { if (e.candidate) sig.current?.send({ type: "ice-candidate", listenerId, candidate: e.candidate });
sig.current?.send({ type: "ice-candidate", listenerId, candidate: e.candidate });
}
}; };
pc.onconnectionstatechange = () => { pc.onconnectionstatechange = () => {
if (["failed", "closed", "disconnected"].includes(pc.connectionState)) { if (["failed", "closed", "disconnected"].includes(pc.connectionState)) {
// Listener will re-request an offer if it comes back.
if (peers.current.get(listenerId) === record) { if (peers.current.get(listenerId) === record) {
try { try { pc.close(); } catch { /* ignore */ }
pc.close();
} catch {
// ignore
}
peers.current.delete(listenerId); peers.current.delete(listenerId);
updateCount(); updateCount();
} }
@@ -84,17 +93,37 @@ export default function Broadcaster() {
sig.current?.send({ type: "offer", listenerId, sdp: pc.localDescription }); sig.current?.send({ type: "offer", listenerId, sdp: pc.localDescription });
}, [updateCount]); }, [updateCount]);
const startMeter = useCallback(() => { const startRecorder = useCallback(() => {
const Ctx = window.AudioContext || window.webkitAudioContext; if (typeof MediaRecorder === "undefined") return;
const ctx = new Ctx(); const picked = pickRecordingMime();
audioCtx.current = ctx; recMime.current = picked;
ctx.resume().catch(() => {}); recChunks.current = [];
const source = ctx.createMediaStreamSource(stream.current); try {
const analyser = ctx.createAnalyser(); const mr = picked.mime
analyser.fftSize = 512; ? new MediaRecorder(stream.current, { mimeType: picked.mime })
source.connect(analyser); : new MediaRecorder(stream.current);
const buf = new Uint8Array(analyser.fftSize); mr.ondataavailable = (e) => {
if (e.data && e.data.size > 0) recChunks.current.push(e.data);
};
mr.onstop = () => {
const blob = new Blob(recChunks.current, { type: picked.mime || "audio/webm" });
const durationMs = Date.now() - recStartMs.current;
setPending({ blob, ext: picked.ext, durationMs });
// Mic is fully released only after the recorder has flushed.
if (stream.current) {
stream.current.getTracks().forEach((t) => t.stop());
stream.current = null;
}
};
recorder.current = mr;
mr.start(1000);
} catch {
recorder.current = null;
}
}, []);
const runMeter = useCallback((analyser) => {
const buf = new Uint8Array(analyser.fftSize);
const tick = () => { const tick = () => {
analyser.getByteTimeDomainData(buf); analyser.getByteTimeDomainData(buf);
let sum = 0; let sum = 0;
@@ -102,8 +131,7 @@ export default function Broadcaster() {
const v = (buf[i] - 128) / 128; const v = (buf[i] - 128) / 128;
sum += v * v; sum += v * v;
} }
const rms = Math.sqrt(sum / buf.length); setMicLevel(Math.min(1, Math.sqrt(sum / buf.length) * 2.2));
setMicLevel(Math.min(1, rms * 2.2));
raf.current = requestAnimationFrame(tick); raf.current = requestAnimationFrame(tick);
}; };
tick(); tick();
@@ -112,24 +140,21 @@ export default function Broadcaster() {
const start = useCallback(async () => { const start = useCallback(async () => {
setError(""); setError("");
setStatus("starting"); setStatus("starting");
setTranscript([]);
setMarkers([]);
setSaved(null);
setPending(null);
markerSeq.current = 0;
try { try {
stream.current = await navigator.mediaDevices.getUserMedia({ stream.current = await navigator.mediaDevices.getUserMedia({
audio: { audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
video: false, video: false,
}); });
} catch (err) { } catch (err) {
setStatus("error"); setStatus("error");
if (err && err.name === "NotAllowedError") { if (err && err.name === "NotAllowedError") setError("Microphone permission was denied. Allow mic access and try again.");
setError("Microphone permission was denied. Allow mic access and try again."); else if (err && err.name === "NotFoundError") setError("No microphone was found on this device.");
} else if (err && err.name === "NotFoundError") { else setError("Could not start the microphone. " + (err?.message || ""));
setError("No microphone was found on this device.");
} else {
setError("Could not start the microphone. " + (err?.message || ""));
}
return; return;
} }
@@ -137,90 +162,139 @@ export default function Broadcaster() {
const s = new Signaling("broadcaster"); const s = new Signaling("broadcaster");
sig.current = s; sig.current = s;
s.on("listener-wants-offer", (m) => { s.on("listener-wants-offer", (m) => {
// Replace any stale peer for this listener id before offering.
const existing = peers.current.get(m.listenerId); const existing = peers.current.get(m.listenerId);
if (existing) { if (existing) {
try { try { existing.pc.close(); } catch { /* ignore */ }
existing.pc.close();
} catch {
// ignore
}
peers.current.delete(m.listenerId); peers.current.delete(m.listenerId);
} }
createPeerFor(m.listenerId).catch((e) => console.warn("offer failed", e)); createPeerFor(m.listenerId).catch((e) => console.warn("offer failed", e));
}); });
s.on("answer", async (m) => { s.on("answer", async (m) => {
const rec = peers.current.get(m.listenerId); const rec = peers.current.get(m.listenerId);
if (!rec) return; if (!rec) return;
await rec.pc.setRemoteDescription(m.sdp); await rec.pc.setRemoteDescription(m.sdp);
for (const c of rec.pending) { for (const c of rec.pending) rec.pc.addIceCandidate(c).catch(() => {});
rec.pc.addIceCandidate(c).catch(() => {});
}
rec.pending = []; rec.pending = [];
}); });
s.on("ice-candidate", (m) => { s.on("ice-candidate", (m) => {
const rec = peers.current.get(m.listenerId); const rec = peers.current.get(m.listenerId);
if (!rec) return; if (!rec) return;
if (rec.pc.remoteDescription) { if (rec.pc.remoteDescription) rec.pc.addIceCandidate(m.candidate).catch(() => {});
rec.pc.addIceCandidate(m.candidate).catch(() => {}); else rec.pending.push(m.candidate);
} else {
rec.pending.push(m.candidate);
}
}); });
s.on("listener-left", (m) => { s.on("listener-left", (m) => {
const rec = peers.current.get(m.listenerId); const rec = peers.current.get(m.listenerId);
if (rec) { if (rec) {
try { try { rec.pc.close(); } catch { /* ignore */ }
rec.pc.close();
} catch {
// ignore
}
peers.current.delete(m.listenerId); peers.current.delete(m.listenerId);
updateCount(); updateCount();
} }
}); });
s.connect(); s.connect();
startMeter();
recStartMs.current = Date.now();
setMarkers([{ offsetMs: 0, type: "event", label: "Broadcast started" }]);
startRecorder();
capture.current = startMicCapture(stream.current, {
intervalMs: TRANSCRIBE_INTERVAL_MS,
onChunk: postChunk,
});
runMeter(capture.current.analyser);
setStatus("live"); setStatus("live");
}, [createPeerFor, startMeter, updateCount]); }, [createPeerFor, postChunk, runMeter, startRecorder, updateCount]);
const stop = useCallback(() => { const stop = useCallback(() => {
teardown(); if (raf.current) cancelAnimationFrame(raf.current);
raf.current = null;
for (const { pc } of peers.current.values()) {
try { pc.close(); } catch { /* ignore */ }
}
peers.current.clear();
setListenerCount(0);
setMicLevel(0);
if (capture.current) { capture.current.stop(); capture.current = null; }
if (sig.current) { sig.current.close(); sig.current = null; }
// Flush the recorder; its onstop assembles the blob and releases the mic.
if (recorder.current && recorder.current.state !== "inactive") {
try { recorder.current.stop(); } catch { /* ignore */ }
} else if (stream.current) {
stream.current.getTracks().forEach((t) => t.stop());
stream.current = null;
}
recorder.current = null;
setStatus("idle"); setStatus("idle");
}, [teardown]); }, []);
const discard = useCallback(() => {
setPending(null);
setTranscript([]);
setMarkers([]);
setTitle("");
}, []);
const save = useCallback(async () => {
if (!pending) return;
setSaving(true);
try {
const qs = new URLSearchParams({
ext: pending.ext,
startedAt: new Date(recStartMs.current).toISOString(),
durationMs: String(pending.durationMs),
});
const up = await fetch(`/recordings?${qs}`, {
method: "POST",
headers: { "Content-Type": pending.blob.type || "audio/webm" },
body: pending.blob,
});
const { id } = await up.json();
await fetch(`/recordings/${id}/markers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
markers,
transcript,
durationMs: pending.durationMs,
title: title.trim() || null,
}),
});
setSaved({ id });
setPending(null);
setTitle("");
} catch (e) {
setError("Could not save the recording. " + (e?.message || ""));
} finally {
setSaving(false);
}
}, [pending, markers, transcript, title]);
// Tear down on unmount and when the page is hidden/closed (FR-B6).
useEffect(() => { useEffect(() => {
const onHide = () => teardown(); const onHide = () => {
if (capture.current) capture.current.stop();
if (recorder.current && recorder.current.state !== "inactive") {
try { recorder.current.stop(); } catch { /* ignore */ }
}
if (stream.current) stream.current.getTracks().forEach((t) => t.stop());
sig.current?.close();
};
window.addEventListener("pagehide", onHide); window.addEventListener("pagehide", onHide);
return () => { return () => {
window.removeEventListener("pagehide", onHide); window.removeEventListener("pagehide", onHide);
teardown(); onHide();
}; };
}, [teardown]); }, []);
const live = status === "live"; const live = status === "live";
return ( return (
<div className="mx-auto flex min-h-full max-w-md flex-col items-center justify-center gap-6 px-5 py-10"> <div className="mx-auto flex min-h-full max-w-md flex-col items-center justify-center gap-5 px-5 py-10">
<header className="flex flex-col items-center gap-2 text-center"> <Header />
<span className="text-sm font-medium uppercase tracking-[0.2em] text-neutral-500">
LiveCast
</span>
<h1 className="text-2xl font-bold">Broadcast</h1>
</header>
<Card className="w-full"> <Card className="w-full">
<CardContent className="flex flex-col items-center gap-6 py-8"> <CardContent className="flex flex-col items-center gap-5 py-7">
<StatusBadge live={live} /> <StatusBadge live={live} />
{/* Mic level meter (FR-B3) */}
<div className="w-full"> <div className="w-full">
<div className="mb-2 flex justify-between text-xs text-neutral-500"> <div className="mb-2 flex justify-between text-xs text-neutral-500">
<span>Mic level</span> <span>Mic level</span>
@@ -228,31 +302,28 @@ export default function Broadcaster() {
</div> </div>
<div className="h-3 w-full overflow-hidden rounded-full bg-neutral-800"> <div className="h-3 w-full overflow-hidden rounded-full bg-neutral-800">
<div <div
className={cn( className={cn("h-full rounded-full transition-[width] duration-75", micLevel > 0.85 ? "bg-live" : "bg-emerald-400")}
"h-full rounded-full transition-[width] duration-75",
micLevel > 0.85 ? "bg-live" : "bg-emerald-400"
)}
style={{ width: `${Math.round(micLevel * 100)}%` }} style={{ width: `${Math.round(micLevel * 100)}%` }}
/> />
</div> </div>
</div> </div>
{/* Listener count (FR-B5) */} <div className="flex w-full items-center justify-between">
<div className="flex flex-col items-center"> <div className="flex flex-col">
<span className="text-4xl font-bold tabular-nums">{listenerCount}</span> <span className="text-3xl font-bold tabular-nums">{listenerCount}</span>
<span className="text-xs uppercase tracking-wide text-neutral-500"> <span className="text-xs uppercase tracking-wide text-neutral-500">
{listenerCount === 1 ? "listener" : "listeners"} {listenerCount === 1 ? "listener" : "listeners"}
</span> </span>
</div> </div>
{live ? (
<Button variant="outline" onClick={addMarker} className="h-12 px-5">
+ Marker{markers.length ? ` (${markers.length})` : ""}
</Button>
) : null}
</div>
{!live ? ( {!live ? (
<Button <Button size="xl" variant="live" className="w-full" disabled={status === "starting"} onClick={start}>
size="xl"
variant="live"
className="w-full"
disabled={status === "starting"}
onClick={start}
>
{status === "starting" ? "Starting..." : "Go Live"} {status === "starting" ? "Starting..." : "Go Live"}
</Button> </Button>
) : ( ) : (
@@ -261,38 +332,123 @@ export default function Broadcaster() {
</Button> </Button>
)} )}
{error ? ( {error ? <p className="text-center text-sm text-live" role="alert">{error}</p> : null}
<p className="text-center text-sm text-live" role="alert">
{error}
</p>
) : null}
</CardContent> </CardContent>
</Card> </Card>
<p className="max-w-xs text-center text-xs text-neutral-600"> {live && transcribeOn ? (
Tap Go Live and grant microphone access. Listeners hear you in real time. <TranscriptPanel transcript={transcript} />
</p> ) : live ? (
<p className="text-center text-xs text-neutral-600">Transcription is disabled on the server.</p>
) : null}
{pending ? (
<SaveCard
title={title}
setTitle={setTitle}
saving={saving}
durationMs={pending.durationMs}
markerCount={markers.length}
lineCount={transcript.length}
onSave={save}
onDiscard={discard}
/>
) : null}
{saved ? (
<Card className="w-full">
<CardContent className="flex flex-col items-center gap-2 py-5">
<span className="text-sm font-semibold text-emerald-400">Recording saved</span>
<Link to="/recordings" className="text-sm text-neutral-300 underline">
Open the recordings library
</Link>
</CardContent>
</Card>
) : null}
<Link to="/recordings" className="text-xs text-neutral-600 underline">
Recordings library
</Link>
</div> </div>
); );
} }
function Header() {
return (
<header className="flex flex-col items-center gap-2 text-center">
<span className="text-sm font-medium uppercase tracking-[0.2em] text-neutral-500">LiveCast</span>
<h1 className="text-2xl font-bold">Broadcast</h1>
</header>
);
}
function StatusBadge({ live }) { function StatusBadge({ live }) {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span className={cn("inline-block h-3 w-3 rounded-full", live ? "bg-live animate-pulse-live" : "bg-neutral-600")} />
className={cn( <span className={cn("text-sm font-semibold uppercase tracking-wide", live ? "text-live" : "text-neutral-500")}>
"inline-block h-3 w-3 rounded-full",
live ? "bg-live animate-pulse-live" : "bg-neutral-600"
)}
/>
<span
className={cn(
"text-sm font-semibold uppercase tracking-wide",
live ? "text-live" : "text-neutral-500"
)}
>
{live ? "On Air" : "Offline"} {live ? "On Air" : "Offline"}
</span> </span>
</div> </div>
); );
} }
function TranscriptPanel({ transcript }) {
const endRef = useRef(null);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [transcript]);
return (
<Card className="w-full">
<CardContent className="py-5">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Live transcript</span>
<span className="text-xs text-neutral-600">{transcript.length} lines</span>
</div>
<div className="max-h-48 space-y-2 overflow-y-auto pr-1 text-sm">
{transcript.length === 0 ? (
<p className="text-neutral-600">Listening...</p>
) : (
transcript.map((t, i) => (
<p key={i} className="leading-snug">
<span className="mr-2 tabular-nums text-xs text-neutral-600">{fmtClock(t.offsetMs)}</span>
<span className="text-neutral-200">{t.text}</span>
</p>
))
)}
<div ref={endRef} />
</div>
</CardContent>
</Card>
);
}
function SaveCard({ title, setTitle, saving, durationMs, markerCount, lineCount, onSave, onDiscard }) {
return (
<Card className="w-full">
<CardContent className="flex flex-col gap-4 py-6">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">Save this recording?</span>
<span className="text-xs text-neutral-500">{fmtClock(durationMs)}</span>
</div>
<p className="text-xs text-neutral-500">
{markerCount} markers, {lineCount} transcript lines aligned to the audio.
</p>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title (optional)"
className="h-11 rounded-xl border border-neutral-700 bg-neutral-900 px-4 text-sm text-neutral-100 outline-none focus:border-neutral-500"
/>
<div className="flex gap-3">
<Button variant="outline" className="flex-1" onClick={onDiscard} disabled={saving}>
Discard
</Button>
<Button className="flex-1" onClick={onSave} disabled={saving}>
{saving ? "Saving..." : "Save"}
</Button>
</div>
</CardContent>
</Card>
);
}
+32
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState, useCallback } from "react"; import { useEffect, useRef, useState, useCallback } from "react";
import { Signaling, fetchIceServers } from "../lib/signaling.js"; import { Signaling, fetchIceServers } from "../lib/signaling.js";
import { fmtClock } from "../lib/capture.js";
import { Button } from "./ui/button.jsx"; import { Button } from "./ui/button.jsx";
import { Card, CardContent } from "./ui/card.jsx"; import { Card, CardContent } from "./ui/card.jsx";
import { cn } from "../lib/utils.js"; import { cn } from "../lib/utils.js";
@@ -10,6 +11,7 @@ export default function Listener() {
const [connected, setConnected] = useState(false); // has the user tapped Connect const [connected, setConnected] = useState(false); // has the user tapped Connect
const [phase, setPhase] = useState("offline"); // offline | connecting | live const [phase, setPhase] = useState("offline"); // offline | connecting | live
const [reconnecting, setReconnecting] = useState(false); const [reconnecting, setReconnecting] = useState(false);
const [captions, setCaptions] = useState([]); // {offsetMs, text}
const sig = useRef(null); const sig = useRef(null);
const pcRef = useRef(null); const pcRef = useRef(null);
@@ -178,6 +180,11 @@ export default function Listener() {
notifiedThisSession.current = false; notifiedThisSession.current = false;
closePeer(); closePeer();
setPhase("offline"); setPhase("offline");
setCaptions([]);
});
s.on("transcript", (m) => {
if (m.text) setCaptions((c) => [...c, { offsetMs: m.offsetMs, text: m.text }]);
}); });
s.on("offer", (m) => { s.on("offer", (m) => {
@@ -253,6 +260,8 @@ export default function Listener() {
</CardContent> </CardContent>
</Card> </Card>
{connected && captions.length > 0 ? <CaptionsPanel captions={captions} /> : null}
{/* Hidden audio sink for the incoming stream (FR-L3). */} {/* Hidden audio sink for the incoming stream (FR-L3). */}
<audio ref={audioEl} autoPlay playsInline className="hidden" /> <audio ref={audioEl} autoPlay playsInline className="hidden" />
@@ -263,6 +272,29 @@ export default function Listener() {
); );
} }
function CaptionsPanel({ captions }) {
const endRef = useRef(null);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [captions]);
return (
<Card className="w-full">
<CardContent className="py-5">
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Live captions</span>
<div className="mt-2 max-h-48 space-y-2 overflow-y-auto pr-1 text-sm">
{captions.map((c, i) => (
<p key={i} className="leading-snug">
<span className="mr-2 tabular-nums text-xs text-neutral-600">{fmtClock(c.offsetMs)}</span>
<span className="text-neutral-200">{c.text}</span>
</p>
))}
<div ref={endRef} />
</div>
</CardContent>
</Card>
);
}
function LiveIndicator({ phase, reconnecting }) { function LiveIndicator({ phase, reconnecting }) {
const live = phase === "live"; const live = phase === "live";
const connecting = phase === "connecting"; const connecting = phase === "connecting";
+163
View File
@@ -0,0 +1,163 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { Link } from "react-router-dom";
import { fmtClock } from "../lib/capture.js";
import { Button } from "./ui/button.jsx";
import { Card, CardContent } from "./ui/card.jsx";
import { cn } from "../lib/utils.js";
// Recordings library: list saved sessions and review one with its audio,
// markers, and time-aligned transcript. Clicking any marker or line seeks the
// audio to that exact point.
export default function Recordings() {
const [list, setList] = useState(null); // null = loading
const [selected, setSelected] = useState(null); // full meta
const [curMs, setCurMs] = useState(0);
const audioRef = useRef(null);
useEffect(() => {
fetch("/recordings")
.then((r) => r.json())
.then((d) => setList(d.recordings || []))
.catch(() => setList([]));
}, []);
const open = useCallback(async (id) => {
setCurMs(0);
try {
const meta = await fetch(`/recordings/${id}/markers`).then((r) => r.json());
setSelected(meta);
} catch {
setSelected(null);
}
}, []);
const seek = useCallback((ms) => {
const a = audioRef.current;
if (!a) return;
a.currentTime = ms / 1000;
a.play().catch(() => {});
}, []);
if (selected) {
return (
<Detail
meta={selected}
audioRef={audioRef}
curMs={curMs}
setCurMs={setCurMs}
onSeek={seek}
onBack={() => setSelected(null)}
/>
);
}
return (
<div className="mx-auto flex min-h-full max-w-lg flex-col gap-4 px-5 py-10">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Recordings</h1>
<Link to="/broadcast" className="text-sm text-neutral-400 underline">Broadcast</Link>
</div>
{list === null ? (
<p className="text-neutral-500">Loading...</p>
) : list.length === 0 ? (
<p className="text-neutral-500">No recordings yet. Go live and save a session.</p>
) : (
list.map((r) => (
<button key={r.id} onClick={() => open(r.id)} className="text-left">
<Card className="transition-colors hover:border-neutral-600">
<CardContent className="flex items-center justify-between py-4">
<div className="flex flex-col">
<span className="font-semibold">{r.title || "Untitled session"}</span>
<span className="text-xs text-neutral-500">
{new Date(r.createdAt).toLocaleString()} · {fmtClock(r.durationMs || 0)}
</span>
</div>
<span className="text-xs text-neutral-500">
{r.markerCount} markers · {r.transcriptCount} lines
</span>
</CardContent>
</Card>
</button>
))
)}
</div>
);
}
function Detail({ meta, audioRef, curMs, setCurMs, onSeek, onBack }) {
const transcript = meta.transcript || [];
const markers = meta.markers || [];
const activeIdx = lastIndexAtOrBefore(transcript, curMs);
return (
<div className="mx-auto flex min-h-full max-w-lg flex-col gap-4 px-5 py-10">
<button onClick={onBack} className="self-start text-sm text-neutral-400 underline">
&larr; All recordings
</button>
<h1 className="text-xl font-bold">{meta.title || "Untitled session"}</h1>
<audio
ref={audioRef}
src={`/recordings/${meta.id}`}
controls
className="w-full"
onTimeUpdate={(e) => setCurMs(e.currentTarget.currentTime * 1000)}
/>
{markers.length ? (
<div>
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Markers</span>
<div className="mt-2 flex flex-wrap gap-2">
{markers.map((m, i) => (
<button
key={i}
onClick={() => onSeek(m.offsetMs)}
className={cn(
"rounded-full border px-3 py-1 text-xs",
m.type === "event"
? "border-neutral-700 text-neutral-400"
: "border-amber-500/40 text-amber-300 hover:bg-amber-500/10"
)}
>
<span className="tabular-nums">{fmtClock(m.offsetMs)}</span> · {m.label}
</button>
))}
</div>
</div>
) : null}
<div>
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Transcript</span>
<div className="mt-2 space-y-1">
{transcript.length === 0 ? (
<p className="text-sm text-neutral-600">No transcript for this recording.</p>
) : (
transcript.map((t, i) => (
<button
key={i}
onClick={() => onSeek(t.offsetMs)}
className={cn(
"block w-full rounded-lg px-2 py-1 text-left text-sm leading-snug",
i === activeIdx ? "bg-neutral-800 text-white" : "text-neutral-300 hover:bg-neutral-900"
)}
>
<span className="mr-2 tabular-nums text-xs text-neutral-600">{fmtClock(t.offsetMs)}</span>
{t.text}
</button>
))
)}
</div>
</div>
</div>
);
}
function lastIndexAtOrBefore(items, ms) {
let idx = -1;
for (let i = 0; i < items.length; i++) {
if (items[i].offsetMs <= ms) idx = i;
else break;
}
return idx;
}
+146
View File
@@ -0,0 +1,146 @@
// Microphone capture helpers for the broadcaster.
//
// startMicCapture taps the mic stream once and provides both:
// - an AnalyserNode for the level meter, and
// - fixed-length 16 kHz mono WAV windows for near-live transcription.
// Using one AudioContext keeps resource use low (important on iOS Safari).
const TARGET_RATE = 16000;
export function startMicCapture(stream, { intervalMs = 5000, onChunk } = {}) {
const Ctx = window.AudioContext || window.webkitAudioContext;
const context = new Ctx();
context.resume().catch(() => {});
const source = context.createMediaStreamSource(stream);
const analyser = context.createAnalyser();
analyser.fftSize = 512;
source.connect(analyser);
const sampleRate = context.sampleRate;
const windowSamples = Math.floor((intervalMs / 1000) * sampleRate);
let pending = []; // Float32Array pieces
let pendingLen = 0;
let windowStartMs = 0;
// ScriptProcessorNode is deprecated but works everywhere including iOS Safari.
const processor = context.createScriptProcessor(4096, 1, 1);
processor.onaudioprocess = (e) => {
const input = e.inputBuffer.getChannelData(0);
pending.push(new Float32Array(input));
pendingLen += input.length;
if (pendingLen >= windowSamples) {
const merged = mergeFloat32(pending, pendingLen);
pending = [];
pendingLen = 0;
const down = downsample(merged, sampleRate, TARGET_RATE);
const wav = encodeWav(down, TARGET_RATE);
const offsetMs = Math.round(windowStartMs);
windowStartMs += (merged.length / sampleRate) * 1000;
if (onChunk) onChunk(wav, offsetMs);
}
};
// Route through a muted gain so the processor runs without the broadcaster
// hearing their own mic.
const zero = context.createGain();
zero.gain.value = 0;
source.connect(processor);
processor.connect(zero);
zero.connect(context.destination);
return {
context,
analyser,
stop() {
try {
processor.disconnect();
zero.disconnect();
analyser.disconnect();
source.disconnect();
} catch {
// ignore
}
context.close().catch(() => {});
},
};
}
function mergeFloat32(pieces, total) {
const out = new Float32Array(total);
let off = 0;
for (const p of pieces) {
out.set(p, off);
off += p.length;
}
return out;
}
// Linear-interpolation downsample to the target rate (mono).
function downsample(input, inRate, outRate) {
if (outRate >= inRate) return input;
const ratio = inRate / outRate;
const outLen = Math.floor(input.length / ratio);
const out = new Float32Array(outLen);
for (let i = 0; i < outLen; i++) {
const idx = i * ratio;
const lo = Math.floor(idx);
const hi = Math.min(lo + 1, input.length - 1);
const frac = idx - lo;
out[i] = input[lo] * (1 - frac) + input[hi] * frac;
}
return out;
}
// Encode mono Float32 samples as a 16-bit PCM WAV Blob.
function encodeWav(samples, rate) {
const buffer = new ArrayBuffer(44 + samples.length * 2);
const view = new DataView(buffer);
const writeStr = (off, s) => {
for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i));
};
writeStr(0, "RIFF");
view.setUint32(4, 36 + samples.length * 2, true);
writeStr(8, "WAVE");
writeStr(12, "fmt ");
view.setUint32(16, 16, true);
view.setUint16(20, 1, true); // PCM
view.setUint16(22, 1, true); // mono
view.setUint32(24, rate, true);
view.setUint32(28, rate * 2, true); // byte rate
view.setUint16(32, 2, true); // block align
view.setUint16(34, 16, true); // bits per sample
writeStr(36, "data");
view.setUint32(40, samples.length * 2, true);
let off = 44;
for (let i = 0; i < samples.length; i++) {
const s = Math.max(-1, Math.min(1, samples[i]));
view.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7fff, true);
off += 2;
}
return new Blob([view], { type: "audio/wav" });
}
// Choose a MediaRecorder mime type the browser supports, with its file ext.
export function pickRecordingMime() {
const candidates = [
{ mime: "audio/webm;codecs=opus", ext: "webm" },
{ mime: "audio/webm", ext: "webm" },
{ mime: "audio/mp4", ext: "m4a" },
{ mime: "audio/mpeg", ext: "mp3" },
];
for (const c of candidates) {
if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(c.mime)) {
return c;
}
}
return { mime: "", ext: "webm" };
}
// mm:ss from milliseconds.
export function fmtClock(ms) {
const s = Math.max(0, Math.floor(ms / 1000));
const m = Math.floor(s / 60);
return `${m}:${String(s % 60).padStart(2, "0")}`;
}
+2
View File
@@ -7,12 +7,14 @@ import {
} from "react-router-dom"; } from "react-router-dom";
import Broadcaster from "./components/Broadcaster.jsx"; import Broadcaster from "./components/Broadcaster.jsx";
import Listener from "./components/Listener.jsx"; import Listener from "./components/Listener.jsx";
import Recordings from "./components/Recordings.jsx";
import "./index.css"; import "./index.css";
const router = createBrowserRouter([ const router = createBrowserRouter([
{ path: "/", element: <Navigate to="/listen" replace /> }, { path: "/", element: <Navigate to="/listen" replace /> },
{ path: "/broadcast", element: <Broadcaster /> }, { path: "/broadcast", element: <Broadcaster /> },
{ path: "/listen", element: <Listener /> }, { path: "/listen", element: <Listener /> },
{ path: "/recordings", element: <Recordings /> },
{ path: "*", element: <Navigate to="/listen" replace /> }, { path: "*", element: <Navigate to="/listen" replace /> },
]); ]);