@juspay/neurolink
Version:
The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002
94 lines (93 loc) • 3.29 kB
JavaScript
/**
* Provider-neutral speaker turns.
*
* Diarizing STT providers disagree about where the speaker label lives:
* Deepgram and Google put it on every word, ElevenLabs Scribe puts it on
* every token, the diarization sidecar puts it on each segment. Callers want
* one shape — a list of turns, each with a speaker, a time range and text —
* so `STTProcessor` derives it here whenever a provider returned speaker
* labels on words but no speaker-labelled segments of its own.
*
* @module utils/speakerTurns
*/
/**
* Merge consecutive same-speaker words into turns.
*
* A word without a speaker continues the current turn (providers emit
* unlabelled punctuation and spacing tokens); a word with a different
* speaker starts a new one. Words are joined with single spaces and the
* result trimmed, so text built from punctuated words reads naturally.
* Returns `[]` when no word carries a speaker, so callers can tell "no
* diarization" apart from "one speaker".
*/
export function buildSpeakerTurns(words) {
if (!words.some((w) => w.speaker !== undefined)) {
return [];
}
const turns = [];
let current;
for (const word of words) {
const speaker = word.speaker ?? current?.speaker;
if (speaker === undefined) {
continue; // leading unlabelled tokens before the first speaker
}
if (!current || current.speaker !== speaker) {
if (current) {
turns.push(toSegment(current.speaker, current.words, turns.length));
}
current = { speaker, words: [] };
}
current.words.push(word);
}
if (current && current.words.length > 0) {
turns.push(toSegment(current.speaker, current.words, turns.length));
}
return turns;
}
function toSegment(speaker, words, index) {
const starts = words
.map((w) => w.startTime ?? w.start)
.filter((t) => typeof t === "number");
const ends = words
.map((w) => w.endTime ?? w.end)
.filter((t) => typeof t === "number");
const confidences = words
.map((w) => w.confidence)
.filter((c) => typeof c === "number");
const segment = {
index,
// Collapse whitespace first, so the punctuation pass only ever sees one
// space; `\s+` before a character class is quadratic on a long run of
// whitespace with no punctuation after it (CodeQL js/polynomial-redos).
text: words
.map((w) => w.word)
.join(" ")
.replace(/\s+/g, " ")
.replace(/ ([,.;:!?])/g, "$1")
.trim(),
isFinal: true,
speaker,
words,
};
if (starts.length > 0) {
segment.startTime = Math.min(...starts);
}
if (ends.length > 0) {
segment.endTime = Math.max(...ends);
}
if (confidences.length > 0) {
segment.confidence =
confidences.reduce((sum, c) => sum + c, 0) / confidences.length;
}
return segment;
}
/** Distinct speaker labels in order of first appearance. */
export function listSpeakers(items) {
const seen = new Set();
for (const item of items) {
if (item.speaker !== undefined) {
seen.add(item.speaker);
}
}
return [...seen];
}