UNPKG

@p0llen/speaker-detector-client

Version:

React components and hooks for live speaker detection and mic analysis.

325 lines (319 loc) 12.1 kB
import _asyncToGenerator from "@babel/runtime/helpers/esm/asyncToGenerator"; // speaker-detector-client/src/hooks/useSpeakerDetection.js import { useEffect, useRef, useState } from "react"; import { withBase } from "../lib/apiBase"; export function useSpeakerDetection() { var { endpoint = "/api/active-speaker", log = true, logOnChangeOnly = true, minDelta = 0.02, smoothing = 1, backgroundLabel = null, // Provided by parent to ensure a single source of truth mode = "off", intervalMs = 3000, threshold = 0.75, sid = null } = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; var minConfidence = threshold; var interval = intervalMs; var [speaker, setSpeaker] = useState(null); var [confidence, setConfidence] = useState(null); var [isSpeaking, setIsSpeaking] = useState(false); var [status, setStatus] = useState("pending"); var [error, setError] = useState(null); var [backendOnline, setBackendOnline] = useState(null); var [altSpeaker, setAltSpeaker] = useState(null); var [altConfidence, setAltConfidence] = useState(null); var lastAnnouncedNameRef = useRef(null); var inFlightRef = useRef(false); var lastSampleRef = useRef({ name: null, confidence: null, status: null, isSpeaking: null }); var confWindowRef = useRef([]); var cooldownUntilRef = useRef(0); var detectionStateRef = useRef("unknown"); // 'running' | 'stopped' | 'unknown' // Backend online indicator via one-time server push (SSE). // The backend should send a single event when online and may close. useEffect(() => { var es; try { es = new EventSource(withBase("/api/online")); var onMsg = () => { setBackendOnline(true); // Close after first signal to avoid a long-lived connection. try { es.close(); } catch (_unused) {} }; es.onmessage = onMsg; es.addEventListener("online", onMsg); es.onerror = () => { // Do not flip to false here to avoid churn; polling failures will set false. }; } catch (_unused2) {} return () => { try { es && es.close && es.close(); } catch (_unused3) {} }; }, []); // Detection state SSE: backend pushes 'running' | 'stopped'. // Client polls /api/active-speaker only when 'running'. useEffect(() => { var es; try { es = new EventSource(withBase("/api/detection-state")); es.onopen = () => setBackendOnline(true); var handle = data => { var raw = (data || "").toString().trim().toLowerCase(); if (raw === "running") { detectionStateRef.current = "running"; } else if (raw === "stopped") { detectionStateRef.current = "stopped"; setStatus("pending"); setSpeaker(null); setConfidence(null); setIsSpeaking(false); } }; es.onmessage = e => handle(e.data); es.addEventListener("detection", e => handle(e.data)); es.onerror = () => { // Keep state; network failures will be reflected by fetch paths. }; } catch (_unused4) {} return () => { try { es && es.close && es.close(); } catch (_unused5) {} }; }, []); useEffect(() => { var id, cancelled = false; var poll = /*#__PURE__*/function () { var _ref = _asyncToGenerator(function* () { if (Date.now() < cooldownUntilRef.current) return; if (mode === "off") { setStatus("disabled"); setSpeaker(null); setConfidence(null); setIsSpeaking(false); return; } if (detectionStateRef.current === "stopped") { // Engine explicitly reported as stopped via SSE setStatus("pending"); setSpeaker(null); setConfidence(null); setIsSpeaking(false); return; } if (inFlightRef.current) return; inFlightRef.current = true; try { var _d$speaker; var hasQ = endpoint.includes("?"); var url = "".concat(endpoint).concat(sid ? (hasQ ? "&" : "?") + "sid=" + encodeURIComponent(sid) : ""); var r = yield fetch(withBase(url), { cache: "no-store" }); if (!r.ok) { // Server reachable but not ready or error if (r.status === 503) { setBackendOnline(true); setStatus("pending"); // engine awaiting data setSpeaker(null); setConfidence(null); setIsSpeaking(false); // Back off a bit to reduce repeated 503s var wait = Math.max(1500, interval || 3000); cooldownUntilRef.current = Date.now() + wait; } else { setBackendOnline(true); setStatus("error"); setSpeaker(null); setConfidence(null); setIsSpeaking(false); // Brief backoff on other errors cooldownUntilRef.current = Date.now() + 1500; } return; } var d = yield r.json(); // Any successful response from backend implies it's reachable setBackendOnline(true); // Any successful response from backend implies it's reachable setBackendOnline(true); if (cancelled) return; var rawSpeaker = (_d$speaker = d.speaker) !== null && _d$speaker !== void 0 ? _d$speaker : null; var rawConfidence = typeof d.confidence === "number" ? d.confidence : null; var backendIsSpeaking = typeof d.is_speaking === "boolean" ? d.is_speaking : null; var stateStatus = (d.status || "pending").trim(); // Optional suggested candidate from backend (supporting multiple shapes) var altName = null; var altConf = null; var pickFromObj = obj => { var _ref2, _obj$confidence; if (!obj || typeof obj !== "object") return { name: null, conf: null }; var name = obj.speaker || obj.name || obj.label || null; var confVal = (_ref2 = (_obj$confidence = obj.confidence) !== null && _obj$confidence !== void 0 ? _obj$confidence : obj.score) !== null && _ref2 !== void 0 ? _ref2 : obj.probability; var conf = typeof confVal === "number" ? confVal : null; return { name, conf }; }; if (d && typeof d === "object") { if (d.suggested !== undefined) { var s = d.suggested; if (Array.isArray(s)) { var top = s[0]; var { name, conf } = pickFromObj(top); altName = typeof top === "string" ? top : name; altConf = typeof top === "object" ? conf : null; } else if (typeof s === "string") { altName = s; altConf = null; } else { var { name: _name, conf: _conf } = pickFromObj(s); altName = _name; altConf = _conf; } } else if (d.candidate) { var { name: _name2, conf: _conf2 } = pickFromObj(d.candidate); altName = _name2; altConf = _conf2; } else if (Array.isArray(d.suggestions)) { var { name: _name3, conf: _conf3 } = pickFromObj(d.suggestions[0]); altName = _name3; altConf = _conf3; } } // optional smoothing for confidence var smoothed = rawConfidence; if (typeof rawConfidence === "number" && smoothing > 1) { var w = confWindowRef.current; w.push(rawConfidence); if (w.length > smoothing) w.shift(); smoothed = w.reduce((a, b) => a + b, 0) / w.length; } var nextConfidence = smoothed; setStatus(stateStatus); setSpeaker(rawSpeaker); setConfidence(nextConfidence); setAltSpeaker(altName); setAltConfidence(altConf); var knownEnough = rawSpeaker && rawSpeaker !== "unknown" && (backgroundLabel ? rawSpeaker !== backgroundLabel : true) && (nextConfidence !== null && nextConfidence !== void 0 ? nextConfidence : 0) >= (minConfidence !== null && minConfidence !== void 0 ? minConfidence : 0.5); var computedIsSpeaking = backendIsSpeaking !== null ? backendIsSpeaking : knownEnough; setIsSpeaking(computedIsSpeaking); // events window.dispatchEvent(new CustomEvent("speaker:update", { detail: { name: rawSpeaker, confidence: nextConfidence, isSpeaking: computedIsSpeaking, status: stateStatus, backendOnline: backendOnline !== false, ts: Date.now() } })); if (knownEnough) { if (lastAnnouncedNameRef.current !== rawSpeaker) { window.dispatchEvent(new CustomEvent("speaker:identified", { detail: { name: rawSpeaker, confidence: nextConfidence, status: stateStatus, ts: Date.now() } })); lastAnnouncedNameRef.current = rawSpeaker; } } else if (lastAnnouncedNameRef.current) { window.dispatchEvent(new CustomEvent("speaker:cleared", { detail: { previous: lastAnnouncedNameRef.current, to: rawSpeaker || "unknown", status: stateStatus, confidence: nextConfidence !== null && nextConfidence !== void 0 ? nextConfidence : 0, ts: Date.now() } })); lastAnnouncedNameRef.current = null; } if (log) { var last = lastSampleRef.current; var changedName = last.name !== rawSpeaker; var changedStatus = last.status !== stateStatus; var changedSpeaking = last.isSpeaking !== computedIsSpeaking; var delta = Math.abs((nextConfidence || 0) - (last.confidence || 0)); var shouldLog = !logOnChangeOnly || changedName || changedStatus || changedSpeaking || delta >= minDelta; if (shouldLog) { var pct = Math.round((nextConfidence || 0) * 100); console.log("??? Detected: ".concat(rawSpeaker, " (").concat(pct, "%) - ").concat(stateStatus, " | isSpeaking=").concat(computedIsSpeaking)); } lastSampleRef.current = { name: rawSpeaker, confidence: nextConfidence, status: stateStatus, isSpeaking: computedIsSpeaking }; } setError(null); } catch (e) { // Network-level failure (server down or CORS) setBackendOnline(false); setError("Backend unreachable"); setStatus("error"); setSpeaker(null); setConfidence(null); setIsSpeaking(false); } finally { inFlightRef.current = false; } }); return function poll() { return _ref.apply(this, arguments); }; }(); poll(); id = setInterval(poll, Math.max(300, interval || 3000)); return () => { cancelled = true; clearInterval(id); }; }, [endpoint, backendOnline, log, logOnChangeOnly, minDelta, smoothing, backgroundLabel, interval, minConfidence, mode]); return { speaker, confidence, isSpeaking, error, status, backendOnline, altSpeaker, altConfidence }; } // Export default and keep named export via the function declaration above export default useSpeakerDetection;