@p0llen/speaker-detector-client
Version:
React components and hooks for live speaker detection and mic analysis.
325 lines (319 loc) • 12.1 kB
JavaScript
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;