@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
248 lines (247 loc) • 9.1 kB
JavaScript
/** Sample rate every engine pass receives. */
export const ENGINE_SAMPLE_RATE = 16000;
const BLOCK_SECONDS = 0.02;
const BLOCK_MS = BLOCK_SECONDS * 1000;
/** Noise-floor window: 75 blocks = 1.5 s. */
const NOISE_WINDOW_BLOCKS = 75;
/** Per-block rise limit of the floor: 1.014^50 ≈ 2× per second. */
const NOISE_RISE_PER_BLOCK = 1.014;
const NOISE_FLOOR_MIN = 0.0005;
/** Starting floor, before the window has seen any quiet block. */
const NOISE_FLOOR_INITIAL = 0.004;
const ONSET_RATIO = 3;
const SUSTAIN_RATIO = 1.8;
const ABS_SUSTAIN_RATIO = 0.58;
/** A pause this long ends a turn that is already past `softCutSeconds`. */
const SOFT_GAP_MS = 320;
/** The hard cut looks for the quietest block in this many trailing seconds. */
const HARD_CUT_SEARCH_SECONDS = 3;
/** Defaults of `STTStreamingOptions` that the endpointer reads. */
export const ENDPOINTER_DEFAULTS = {
endSilenceMs: 750,
softCutSeconds: 16,
maxUtteranceSeconds: 28,
onsetRms: 0.006,
prerollMs: 350,
minSpeechMs: 250,
};
/** Applies the defaults to caller tuning; non-finite or non-positive values fall back. */
export function resolveEndpointerConfig(options, inputRate) {
const pick = (value, fallback) => typeof value === "number" && Number.isFinite(value) && value > 0
? value
: fallback;
const d = ENDPOINTER_DEFAULTS;
return {
inputRate: pick(inputRate, ENGINE_SAMPLE_RATE),
endSilenceMs: pick(options?.endSilenceMs, d.endSilenceMs),
softCutSeconds: pick(options?.softCutSeconds, d.softCutSeconds),
maxUtteranceSeconds: pick(options?.maxUtteranceSeconds, d.maxUtteranceSeconds),
onsetRms: pick(options?.onsetRms, d.onsetRms),
prerollMs: pick(options?.prerollMs, d.prerollMs),
minSpeechMs: pick(options?.minSpeechMs, d.minSpeechMs),
};
}
export function rmsOf(samples) {
if (samples.length === 0) {
return 0;
}
let sum = 0;
for (let i = 0; i < samples.length; i++) {
sum += samples[i] * samples[i];
}
return Math.sqrt(sum / samples.length);
}
/** Linear-interpolation resampler to 16 kHz; returns the input untouched when it already is. */
export function resampleTo16k(samples, inputRate) {
if (inputRate === ENGINE_SAMPLE_RATE) {
return samples;
}
const ratio = inputRate / ENGINE_SAMPLE_RATE;
const out = new Float32Array(Math.floor(samples.length / ratio));
for (let i = 0; i < out.length; i++) {
const pos = i * ratio;
const i0 = Math.floor(pos);
const i1 = Math.min(i0 + 1, samples.length - 1);
out[i] = samples[i0] + (samples[i1] - samples[i0]) * (pos - i0);
}
return out;
}
export class Endpointer {
config;
blockLen;
block;
blockFill = 0;
noise = NOISE_FLOOR_INITIAL;
noiseWin = [];
pre = [];
preLen = 0;
utt = null;
seq = 0;
/** Input samples that went through the gate so far: the audio clock, in whole blocks. */
consumed = 0;
constructor(config) {
this.config = config;
this.blockLen = Math.max(1, Math.round(BLOCK_SECONDS * config.inputRate));
this.block = new Float32Array(this.blockLen);
}
/** The utterance being built, if speech is in progress. */
get current() {
return this.utt;
}
/** Seconds of input consumed so far. */
get audioSeconds() {
return this.consumed / this.config.inputRate;
}
/** Feeds samples at the input rate; returns the events they caused, in order. */
push(samples) {
const events = [];
let i = 0;
while (i < samples.length) {
const n = Math.min(samples.length - i, this.blockLen - this.blockFill);
this.block.set(samples.subarray(i, i + n), this.blockFill);
this.blockFill += n;
i += n;
if (this.blockFill === this.blockLen) {
this.onBlock(this.block, events);
this.block = new Float32Array(this.blockLen);
this.blockFill = 0;
}
}
return events;
}
/**
* Ends the stream: a partial block is folded into the open utterance, which
* is then ended (`flush`) — or dropped when it is a cough nobody heard.
*/
flush() {
const events = [];
const u = this.utt;
if (u && this.blockFill > 0) {
const tail = this.block.slice(0, this.blockFill);
u.blocks.push(tail);
u.rms.push(rmsOf(tail));
u.length += tail.length;
}
this.consumed += this.blockFill;
this.blockFill = 0;
if (u) {
this.utt = null;
events.push(u.speechMs >= this.config.minSpeechMs || u.heard
? { type: "end", utterance: u, reason: "flush" }
: { type: "drop", utterance: u });
}
this.pre = [];
this.preLen = 0;
return events;
}
/** The utterance's audio at 16 kHz. */
pcm16k(u) {
const out = new Float32Array(u.length);
let o = 0;
for (const b of u.blocks) {
out.set(b, o);
o += b.length;
}
return resampleTo16k(out, this.config.inputRate);
}
sustainThreshold() {
return Math.max(this.config.onsetRms * ABS_SUSTAIN_RATIO, this.noise * SUSTAIN_RATIO);
}
newUtterance(blocks, rms) {
const length = blocks.reduce((a, b) => a + b.length, 0);
return {
id: ++this.seq,
// Called once the newest block is counted, so the utterance ends at the clock.
startSeconds: (this.consumed - length) / this.config.inputRate,
blocks,
rms,
length,
speechMs: 0,
silenceMs: 0,
heard: false,
};
}
onBlock(block, events) {
const cfg = this.config;
const rms = rmsOf(block);
this.consumed += block.length;
// Minimum statistic over the window; a drop is followed at once, a rise is rate-limited.
this.noiseWin.push(rms);
if (this.noiseWin.length > NOISE_WINDOW_BLOCKS) {
this.noiseWin.shift();
}
let winMin = Infinity;
for (const v of this.noiseWin) {
if (v < winMin) {
winMin = v;
}
}
this.noise = Math.max(NOISE_FLOOR_MIN, winMin > this.noise
? Math.min(winMin, this.noise * NOISE_RISE_PER_BLOCK)
: winMin);
const onset = rms > Math.max(cfg.onsetRms, this.noise * ONSET_RATIO);
const sustain = rms > this.sustainThreshold();
const u = this.utt;
if (!u) {
this.pre.push({ block, rms });
this.preLen += block.length;
const maxPre = (cfg.prerollMs / 1000) * cfg.inputRate;
while (this.preLen > maxPre && this.pre.length > 1) {
const dropped = this.pre.shift();
this.preLen -= dropped ? dropped.block.length : 0;
}
if (onset) {
const started = this.newUtterance(this.pre.map((p) => p.block), this.pre.map((p) => p.rms));
started.speechMs = BLOCK_MS;
this.utt = started;
this.pre = [];
this.preLen = 0;
events.push({ type: "start", utterance: started });
}
return;
}
u.blocks.push(block);
u.rms.push(rms);
u.length += block.length;
if (sustain) {
u.speechMs += BLOCK_MS;
u.silenceMs = 0;
}
else {
u.silenceMs += BLOCK_MS;
}
if (u.silenceMs >= cfg.endSilenceMs) {
this.utt = null;
events.push(u.speechMs >= cfg.minSpeechMs || u.heard
? { type: "end", utterance: u, reason: "silence" }
: { type: "drop", utterance: u });
}
else if (u.length >= cfg.softCutSeconds * cfg.inputRate &&
u.silenceMs >= SOFT_GAP_MS) {
this.utt = null;
events.push({ type: "end", utterance: u, reason: "softCut" });
}
else if (u.length >= cfg.maxUtteranceSeconds * cfg.inputRate) {
this.hardCut(u, events);
}
}
hardCut(u, events) {
const n = u.blocks.length;
const from = Math.max(1, n - Math.round(HARD_CUT_SEARCH_SECONDS / BLOCK_SECONDS));
let best = from;
for (let k = from; k < n; k++) {
if (u.rms[k] < u.rms[best]) {
best = k;
}
}
const restBlocks = u.blocks.splice(best);
const restRms = u.rms.splice(best);
u.length = u.blocks.reduce((a, b) => a + b.length, 0);
const next = this.newUtterance(restBlocks, restRms);
const threshold = this.sustainThreshold();
next.speechMs = restRms.filter((r) => r > threshold).length * BLOCK_MS;
this.utt = next;
events.push({ type: "end", utterance: u, reason: "hardCut" });
events.push({ type: "start", utterance: next });
}
}