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:
co-authored by
Claude Opus 4.8
parent
5c34634e63
commit
e00d844f06
@@ -3,3 +3,5 @@ dist/
|
|||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
recordings/
|
||||||
|
server/recordings/
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
+298
-142
@@ -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;
|
try {
|
||||||
for (const { pc } of peers.current.values()) {
|
const res = await fetch(`/transcribe?offset=${offsetMs}`, {
|
||||||
try {
|
method: "POST",
|
||||||
pc.close();
|
headers: { "Content-Type": "audio/wav" },
|
||||||
} catch {
|
body: wavBlob,
|
||||||
// ignore
|
});
|
||||||
|
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 {
|
||||||
|
// 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>
|
||||||
|
{live ? (
|
||||||
|
<Button variant="outline" onClick={addMarker} className="h-12 px-5">
|
||||||
|
+ Marker{markers.length ? ` (${markers.length})` : ""}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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">
|
||||||
|
← 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;
|
||||||
|
}
|
||||||
@@ -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")}`;
|
||||||
|
}
|
||||||
@@ -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 /> },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user