@juspay/neurolink 13.7.0 → 13.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +3 -6
  2. package/dist/browser/neurolink.min.js +480 -460
  3. package/dist/cli/commands/serve.js +11 -1
  4. package/dist/cli/commands/transcribe.js +39 -3
  5. package/dist/constants/langfuse.d.ts +2 -0
  6. package/dist/constants/langfuse.js +2 -0
  7. package/dist/factories/sttDescriptors.js +7 -0
  8. package/dist/index.d.ts +3 -2
  9. package/dist/index.js +4 -2
  10. package/dist/mcp/externalServerManager.js +4 -1
  11. package/dist/mcp/mcpClientFactory.d.ts +6 -0
  12. package/dist/mcp/mcpClientFactory.js +44 -0
  13. package/dist/neurolink.js +42 -3
  14. package/dist/observability/exporters/langfuseExporter.d.ts +4 -0
  15. package/dist/observability/exporters/langfuseExporter.js +29 -8
  16. package/dist/observability/otlpEncoding.js +2 -2
  17. package/dist/server/abstract/baseServerAdapter.js +2 -0
  18. package/dist/server/adapters/expressAdapter.js +1 -1
  19. package/dist/server/adapters/fastifyAdapter.js +4 -1
  20. package/dist/server/adapters/honoAdapter.js +1 -1
  21. package/dist/server/adapters/koaAdapter.js +1 -1
  22. package/dist/server/routes/transcribeRoutes.d.ts +0 -8
  23. package/dist/server/routes/transcribeRoutes.js +130 -3
  24. package/dist/server/utils/validation.d.ts +409 -15
  25. package/dist/server/utils/validation.js +106 -4
  26. package/dist/server/websocket/frameQueue.d.ts +16 -0
  27. package/dist/server/websocket/frameQueue.js +55 -0
  28. package/dist/server/websocket/realtimeTranscription.d.ts +48 -0
  29. package/dist/server/websocket/realtimeTranscription.js +603 -0
  30. package/dist/server/websocket/transcribeWebSocket.d.ts +17 -1
  31. package/dist/server/websocket/transcribeWebSocket.js +263 -64
  32. package/dist/services/server/ai/observability/langfuseSetup.js +6 -1
  33. package/dist/types/cli.d.ts +6 -0
  34. package/dist/types/exporter.d.ts +3 -1
  35. package/dist/types/server.d.ts +304 -2
  36. package/dist/types/stt.d.ts +38 -3
  37. package/dist/types/transcribe.d.ts +401 -5
  38. package/dist/voice/audioLevel.d.ts +14 -0
  39. package/dist/voice/audioLevel.js +60 -0
  40. package/dist/voice/codeMix.d.ts +40 -0
  41. package/dist/voice/codeMix.js +282 -0
  42. package/dist/voice/correction/align.d.ts +12 -0
  43. package/dist/voice/correction/align.js +48 -0
  44. package/dist/voice/correction/guards.d.ts +39 -0
  45. package/dist/voice/correction/guards.js +122 -0
  46. package/dist/voice/correction/index.d.ts +25 -3
  47. package/dist/voice/correction/index.js +132 -4
  48. package/dist/voice/correction/rewrite.d.ts +64 -3
  49. package/dist/voice/correction/rewrite.js +295 -25
  50. package/dist/voice/eval/sttEvalLangfuse.d.ts +14 -0
  51. package/dist/voice/eval/sttEvalLangfuse.js +182 -0
  52. package/dist/voice/eval/sttEvalStore.d.ts +27 -0
  53. package/dist/voice/eval/sttEvalStore.js +397 -0
  54. package/dist/voice/index.js +3 -0
  55. package/dist/voice/languages.d.ts +34 -0
  56. package/dist/voice/languages.js +290 -0
  57. package/dist/voice/modelAssets.d.ts +28 -0
  58. package/dist/voice/modelAssets.js +113 -0
  59. package/dist/voice/output/render.d.ts +32 -0
  60. package/dist/voice/output/render.js +368 -0
  61. package/dist/voice/providers/OpenAISTT.d.ts +11 -1
  62. package/dist/voice/providers/OpenAISTT.js +57 -6
  63. package/dist/voice/session/transcriptionSession.d.ts +55 -0
  64. package/dist/voice/session/transcriptionSession.js +225 -0
  65. package/dist/voice/streaming/chunkedStreamAdapter.d.ts +7 -0
  66. package/dist/voice/streaming/chunkedStreamAdapter.js +711 -28
  67. package/dist/voice/streaming/endpointer.d.ts +50 -17
  68. package/dist/voice/streaming/endpointer.js +115 -19
  69. package/dist/voice/transcribe.d.ts +18 -1
  70. package/dist/voice/transcribe.js +459 -18
  71. package/dist/voice/transcribeStream.d.ts +18 -1
  72. package/dist/voice/transcribeStream.js +413 -16
  73. package/dist/voice/vad/assets.d.ts +36 -0
  74. package/dist/voice/vad/assets.js +142 -0
  75. package/dist/voice/vad/frameScorer.d.ts +28 -0
  76. package/dist/voice/vad/frameScorer.js +90 -0
  77. package/dist/voice/vad/sileroVad.d.ts +43 -0
  78. package/dist/voice/vad/sileroVad.js +178 -0
  79. package/dist/voice/whistle/assets.d.ts +3 -4
  80. package/dist/voice/whistle/assets.js +11 -95
  81. package/docs-site/static/search-index.json +21 -6
  82. package/models/silero-vad/README.md +19 -0
  83. package/models/silero-vad/silero_vad.onnx +0 -0
  84. package/package.json +3 -1
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Output languages: the transcript rendered in other languages and scripts.
3
+ *
4
+ * One structured model call renders every requested target for a batch of
5
+ * lines (an utterance, or up to 20 segments of a recording), from the
6
+ * native-script engine text plus its corrected form, with earlier lines and
7
+ * their renderings as context. Targets that are the transcript's own
8
+ * language and script need no call at all.
9
+ *
10
+ * A rendering is never allowed to cost the transcript anything: it runs
11
+ * after the text is settled, fails open, and reports what happened in its
12
+ * `status`. The guards refuse an answer that is empty, in the wrong script
13
+ * for its tag, or the source handed back untranslated; a refused target gets
14
+ * one retry on its own.
15
+ *
16
+ * @module voice/output/render
17
+ */
18
+ import { z } from "zod";
19
+ import { TimeoutError, withTimeout } from "../../utils/async/withTimeout.js";
20
+ import { describeOutputTag, dominantScript, normalizeLanguageCode, parseOutputTag, scriptShare, } from "../languages.js";
21
+ /** Most targets one request renders; more are split into parallel requests. */
22
+ const TARGETS_PER_CALL = 4;
23
+ /** Most lines one request renders. */
24
+ const LINES_PER_CALL = 20;
25
+ /** Requests in flight at once for one transcript. */
26
+ const RENDER_CONCURRENCY = 4;
27
+ /** Distinct targets accepted per request. */
28
+ export const MAX_OUTPUT_LANGUAGES = 8;
29
+ const DEFAULT_RENDER_TIMEOUT_MS = 15_000;
30
+ const MAX_RENDER_TOKENS = 8192;
31
+ /** A rendering whose letters are less than this share in the tag's script is refused. */
32
+ const MIN_SCRIPT_SHARE = 0.5;
33
+ /** The tag that means "the transcript as it is". */
34
+ export const SOURCE_OUTPUT = "source";
35
+ /**
36
+ * The output languages in effect right now: read from a live source when
37
+ * one was given, trimmed, de-duplicated (case-insensitively) and capped.
38
+ */
39
+ export function outputLanguagesOf(output) {
40
+ if (!output) {
41
+ return [];
42
+ }
43
+ let raw;
44
+ try {
45
+ raw = Array.isArray(output.languages)
46
+ ? output.languages
47
+ : output.languages.current();
48
+ }
49
+ catch {
50
+ return [];
51
+ }
52
+ const seen = new Set();
53
+ const out = [];
54
+ for (const entry of raw) {
55
+ const tag = typeof entry === "string" ? entry.trim() : "";
56
+ if (tag && !seen.has(tag.toLowerCase())) {
57
+ seen.add(tag.toLowerCase());
58
+ out.push(tag);
59
+ }
60
+ if (out.length >= MAX_OUTPUT_LANGUAGES) {
61
+ break;
62
+ }
63
+ }
64
+ return out;
65
+ }
66
+ function words(text) {
67
+ return text.split(/\s+/u).filter((w) => /[\p{L}\p{N}]/u.test(w)).length;
68
+ }
69
+ function comparable(text) {
70
+ return text
71
+ .toLowerCase()
72
+ .normalize("NFKC")
73
+ .replace(/[^\p{L}\p{N}]+/gu, " ")
74
+ .trim();
75
+ }
76
+ /** Why a rendered line is refused, or `undefined` when it is accepted. */
77
+ function refuseLine(rendered, sourceLine, correctedLine, target, spoken) {
78
+ if (!rendered.trim()) {
79
+ return "empty";
80
+ }
81
+ const letters = rendered.replace(/[^\p{L}]/gu, "").length;
82
+ if (letters >= 3 && scriptShare(rendered, target.script) < MIN_SCRIPT_SHARE) {
83
+ return `not in ${target.script} script`;
84
+ }
85
+ // The source handed back unchanged is not a translation. Only judged when
86
+ // the spoken language is known to differ and the line is a real sentence:
87
+ // a name or "OK" reads the same in every language.
88
+ if (spoken &&
89
+ spoken !== target.language &&
90
+ words(sourceLine) >= 3 &&
91
+ (comparable(rendered) === comparable(sourceLine) ||
92
+ comparable(rendered) === comparable(correctedLine))) {
93
+ return "untranslated";
94
+ }
95
+ return undefined;
96
+ }
97
+ function buildSystemPrompt(glossary, multiline) {
98
+ const rules = [
99
+ "- Translate the meaning faithfully and completely: never summarize, explain, add or leave anything out. " +
100
+ "Keep the speaker's tone and register.",
101
+ "- An output in the language that was spoken is not a translation: keep the speaker's words and only " +
102
+ "write them in the requested writing system.",
103
+ '- An output named "<language> (Latin script)" is that language written in Latin letters the way people ' +
104
+ 'type it on phones (Hinglish: "mera naam", "kal milte hain"), never English. When the speaker ' +
105
+ "spoke another language (English included), translate into that language first: the sentence must " +
106
+ "read as that language, keeping only names, terms and the few English words its speakers use as is.",
107
+ "- Code-mixed speech is rendered entirely in the output language, except names, brands and glossary terms.",
108
+ "- Keep names, numbers (as digits), units, emails, URLs and code exactly.",
109
+ "- The lines come from speech recognition and can hold mis-heard words: render what the speaker most " +
110
+ "plausibly meant, using the cleaned version when one is given.",
111
+ ];
112
+ if (glossary.length) {
113
+ rules.push(`- Keep these terms exactly as written, in every output: ${glossary.map((t) => `"${t}"`).join(", ")}.`);
114
+ }
115
+ rules.push(multiline
116
+ ? "- For every output return one string per input line, in the same order, as many strings as there are lines."
117
+ : "- For every output return a list with exactly one string: the rendered line.");
118
+ return [
119
+ "You are a professional interpreter writing out live speech in other languages.",
120
+ "For every requested output, render the speaker's lines in that language and writing system:",
121
+ ...rules,
122
+ "Answer with a JSON object whose keys are the output tags, each holding the list of rendered lines.",
123
+ ].join("\n");
124
+ }
125
+ function buildUserPrompt(source, corrected, spoken, targets, previous) {
126
+ const parts = [
127
+ `Spoken language: ${spoken ? `${describeOutputTag(spoken)} (${spoken})` : "not identified"}`,
128
+ "Outputs:",
129
+ ...targets.map((t) => `- ${t.tag}: ${describeOutputTag(t.tag)}`),
130
+ ];
131
+ const context = (previous ?? []).filter((p) => p.text.trim());
132
+ if (context.length) {
133
+ parts.push("", "Earlier in the conversation (context only, do not render these):", ...context.map((p, i) => {
134
+ const known = targets
135
+ .map((t) => p.outputs[t.tag] ? ` | ${t.tag}: ${p.outputs[t.tag]}` : "")
136
+ .join("");
137
+ return `${i + 1}. ${p.text}${known}`;
138
+ }));
139
+ }
140
+ parts.push("", "Lines to render (as recognized):", ...source.map((line, i) => `${i + 1}. ${line}`));
141
+ const cleaned = corrected?.some((line, i) => line !== source[i]);
142
+ if (corrected && cleaned) {
143
+ parts.push("", "Cleaned version of the same lines:", ...corrected.map((line, i) => `${i + 1}. ${line}`));
144
+ }
145
+ return parts.join("\n");
146
+ }
147
+ function maxTokensFor(lines, targets) {
148
+ const chars = lines.reduce((n, line) => n + line.length, 0);
149
+ return Math.min(MAX_RENDER_TOKENS, Math.ceil(120 + targets * (40 + 2.5 * chars)));
150
+ }
151
+ /** The object a render call returned, as lines per tag; `undefined` entries were missing or malformed. */
152
+ function readAnswer(answer, tags, lines) {
153
+ let value = answer;
154
+ if (typeof value === "string") {
155
+ try {
156
+ value = JSON.parse(value);
157
+ }
158
+ catch {
159
+ value = undefined;
160
+ }
161
+ }
162
+ const out = new Map();
163
+ const record = typeof value === "object" && value !== null && !Array.isArray(value)
164
+ ? value
165
+ : {};
166
+ for (const tag of tags) {
167
+ const entry = record[tag];
168
+ const list = Array.isArray(entry)
169
+ ? entry.map((v) => (typeof v === "string" ? v.trim() : ""))
170
+ : typeof entry === "string" && lines === 1
171
+ ? [entry.trim()]
172
+ : undefined;
173
+ out.set(tag, list && list.length === lines ? list : undefined);
174
+ }
175
+ return out;
176
+ }
177
+ /** One request: some lines, some targets. Never throws. */
178
+ async function renderBatch(input, range, targets, render, spoken,
179
+ /** Set on a retry: why the first answer was refused. The retry goes without context, which is what misled it. */
180
+ refused) {
181
+ const source = input.source.slice(range.from, range.to);
182
+ const corrected = input.corrected?.slice(range.from, range.to);
183
+ const glossary = input.options?.glossary === false
184
+ ? []
185
+ : [
186
+ ...new Set((input.dictionary ?? []).map((d) => d.term.trim()).filter(Boolean)),
187
+ ];
188
+ const tags = targets.map((t) => t.tag);
189
+ const shape = {};
190
+ for (const tag of tags) {
191
+ shape[tag] = z.array(z.string());
192
+ }
193
+ const timeoutMs = input.options?.timeoutMs && input.options.timeoutMs > 0
194
+ ? input.options.timeoutMs
195
+ : DEFAULT_RENDER_TIMEOUT_MS;
196
+ const out = new Map();
197
+ let answer;
198
+ try {
199
+ answer = await withTimeout(render({
200
+ system: buildSystemPrompt(glossary, source.length > 1),
201
+ user: buildUserPrompt(source, corrected, spoken, targets, refused ? undefined : input.previous) +
202
+ (refused
203
+ ? `\n\nYour previous answer for ${targets.map((t) => t.tag).join(", ")} was refused (${refused}). ` +
204
+ (refused === "untranslated"
205
+ ? `It repeated the lines instead of rendering them in ${targets.map((t) => describeOutputTag(t.tag)).join(", ")}.`
206
+ : "Answer again following every rule.")
207
+ : ""),
208
+ schema: z.object(shape),
209
+ ...(input.options?.provider
210
+ ? { provider: input.options.provider }
211
+ : {}),
212
+ ...(input.options?.model ? { model: input.options.model } : {}),
213
+ maxTokens: maxTokensFor(source, targets.length),
214
+ timeoutMs,
215
+ }), timeoutMs);
216
+ }
217
+ catch (error) {
218
+ const status = error instanceof TimeoutError ? "timeout" : "error";
219
+ for (const tag of tags) {
220
+ out.set(tag, {
221
+ status,
222
+ reason: error instanceof Error ? error.message : String(error),
223
+ });
224
+ }
225
+ return out;
226
+ }
227
+ const parsed = readAnswer(answer, tags, source.length);
228
+ for (const target of targets) {
229
+ const lines = parsed.get(target.tag);
230
+ if (!lines) {
231
+ out.set(target.tag, {
232
+ status: "rejected",
233
+ reason: "missing or malformed in the answer",
234
+ });
235
+ continue;
236
+ }
237
+ let reason;
238
+ for (let i = 0; i < source.length && !reason; i++) {
239
+ if (!source[i].trim()) {
240
+ continue;
241
+ }
242
+ reason = refuseLine(lines[i], source[i], corrected?.[i] ?? source[i], target, spoken);
243
+ }
244
+ out.set(target.tag, reason ? { status: "rejected", reason } : { lines, status: "ok" });
245
+ }
246
+ return out;
247
+ }
248
+ async function mapLimited(items, limit, task) {
249
+ const results = new Array(items.length);
250
+ let next = 0;
251
+ const worker = async () => {
252
+ while (next < items.length) {
253
+ const index = next++;
254
+ results[index] = await task(items[index]);
255
+ }
256
+ };
257
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
258
+ return results;
259
+ }
260
+ /**
261
+ * Render one transcript (one or more lines) into every requested language.
262
+ * Returns one entry per requested tag, keyed by the tag as given. Never throws.
263
+ */
264
+ export async function renderOutputs(input, deps) {
265
+ const started = Date.now();
266
+ const result = {};
267
+ const lineCount = input.source.length;
268
+ const base = input.source.map((line, i) => input.corrected?.[i]?.trim() || line);
269
+ const baseText = base.filter((l) => l.trim()).join(" ");
270
+ const spoken = normalizeLanguageCode(input.language);
271
+ const multi = lineCount > 1;
272
+ const finish = (tag, status, lines, reason) => {
273
+ const text = (lines ?? []).filter((l) => l.trim()).join(" ");
274
+ result[tag] = {
275
+ language: tag,
276
+ text,
277
+ status,
278
+ ms: Date.now() - started,
279
+ ...(multi && lines
280
+ ? { segments: lines.map((t, index) => ({ index, text: t })) }
281
+ : {}),
282
+ ...(reason ? { reason } : {}),
283
+ };
284
+ };
285
+ const pending = [];
286
+ for (const tag of input.languages) {
287
+ if (tag.toLowerCase() === SOURCE_OUTPUT) {
288
+ finish(tag, "same-language", base);
289
+ continue;
290
+ }
291
+ const parsed = parseOutputTag(tag);
292
+ if (!parsed) {
293
+ finish(tag, "error", undefined, "not a BCP-47 language tag");
294
+ continue;
295
+ }
296
+ if (!baseText.trim()) {
297
+ finish(tag, "same-language", base);
298
+ continue;
299
+ }
300
+ // The transcript's own language in its own script: nothing to render.
301
+ if (spoken === parsed.language &&
302
+ dominantScript(baseText) !== undefined &&
303
+ scriptShare(baseText, parsed.script) >= 0.8) {
304
+ finish(tag, "same-language", base);
305
+ continue;
306
+ }
307
+ pending.push(parsed);
308
+ }
309
+ if (pending.length === 0) {
310
+ return result;
311
+ }
312
+ if (!deps.render) {
313
+ for (const t of pending) {
314
+ finish(t.tag, "error", undefined, "no rendering model is wired");
315
+ }
316
+ return result;
317
+ }
318
+ const render = deps.render;
319
+ const ranges = [];
320
+ for (let from = 0; from < lineCount; from += LINES_PER_CALL) {
321
+ ranges.push({ from, to: Math.min(lineCount, from + LINES_PER_CALL) });
322
+ }
323
+ const groups = [];
324
+ for (let i = 0; i < pending.length; i += TARGETS_PER_CALL) {
325
+ groups.push(pending.slice(i, i + TARGETS_PER_CALL));
326
+ }
327
+ const jobs = ranges.flatMap((range) => groups.map((group) => ({ range, group })));
328
+ const outcomes = await mapLimited(jobs, RENDER_CONCURRENCY, async (job) => {
329
+ const first = await renderBatch(input, job.range, job.group, render, spoken);
330
+ // A refused or malformed target gets one more try on its own.
331
+ const retry = job.group.filter((t) => first.get(t.tag)?.status === "rejected");
332
+ for (const target of retry) {
333
+ const again = await renderBatch(input, job.range, [target], render, spoken, first.get(target.tag)?.reason ?? "rejected");
334
+ const outcome = again.get(target.tag);
335
+ if (outcome?.status === "ok") {
336
+ first.set(target.tag, outcome);
337
+ }
338
+ }
339
+ return { range: job.range, outcomes: first };
340
+ });
341
+ for (const target of pending) {
342
+ const lines = new Array(lineCount).fill("");
343
+ let ok = 0;
344
+ let failure;
345
+ for (const { range, outcomes: byTag } of outcomes) {
346
+ const outcome = byTag.get(target.tag);
347
+ if (outcome?.status === "ok" && outcome.lines) {
348
+ ok++;
349
+ outcome.lines.forEach((line, k) => {
350
+ lines[range.from + k] = line;
351
+ });
352
+ }
353
+ else {
354
+ failure ??= outcome;
355
+ }
356
+ }
357
+ if (ok === ranges.length) {
358
+ finish(target.tag, "ok", lines);
359
+ }
360
+ else if (ok > 0) {
361
+ finish(target.tag, "partial", lines, failure?.reason);
362
+ }
363
+ else {
364
+ finish(target.tag, failure?.status ?? "error", undefined, failure?.reason);
365
+ }
366
+ }
367
+ return result;
368
+ }
@@ -12,6 +12,12 @@ export declare class OpenAISTT implements STTHandler {
12
12
  private readonly defaultTimeoutMs;
13
13
  /** The `model` sent when the request names none. */
14
14
  private readonly defaultModel;
15
+ /** `language` template (`{lang}`) that carries a prior to this server; `null` sends none. */
16
+ private readonly priorLanguageForm;
17
+ /** Model name that answers with a language verdict only; `null` when the server has none. */
18
+ private readonly lidModel;
19
+ /** Whether dictionary terms are sent as a prompt; off for servers that do not bias on it. */
20
+ private readonly vocabularyPrompt;
15
21
  /**
16
22
  * Maximum audio duration in seconds (25 minutes)
17
23
  */
@@ -27,6 +33,7 @@ export declare class OpenAISTT implements STTHandler {
27
33
  * @param baseUrl - Base URL, positional form only.
28
34
  */
29
35
  constructor(apiKeyOrCredentials?: string | STTCredentials["whisper"], baseUrl?: string);
36
+ languageProbeModel(): string | undefined;
30
37
  /** True when pointed at OpenAI itself rather than a compatible server. */
31
38
  private get isOfficialEndpoint();
32
39
  /**
@@ -47,7 +54,10 @@ export declare class OpenAISTT implements STTHandler {
47
54
  /**
48
55
  * Optional language-identification fields a self-hosted server may add to
49
56
  * `verbose_json`. Each is read only when it has the expected shape; a
50
- * server that sends none (OpenAI itself) contributes nothing.
57
+ * server that sends none (OpenAI itself) contributes nothing. The top-level
58
+ * fields win; when a gateway has stripped them (LiteLLM rewrites the top
59
+ * level to OpenAI's schema but keeps extra keys inside segments), the same
60
+ * fields on the first segment are read instead.
51
61
  */
52
62
  private static readLanguageExtensions;
53
63
  /**
@@ -63,6 +63,12 @@ export class OpenAISTT {
63
63
  defaultTimeoutMs;
64
64
  /** The `model` sent when the request names none. */
65
65
  defaultModel;
66
+ /** `language` template (`{lang}`) that carries a prior to this server; `null` sends none. */
67
+ priorLanguageForm;
68
+ /** Model name that answers with a language verdict only; `null` when the server has none. */
69
+ lidModel;
70
+ /** Whether dictionary terms are sent as a prompt; off for servers that do not bias on it. */
71
+ vocabularyPrompt;
66
72
  /**
67
73
  * Maximum audio duration in seconds (25 minutes)
68
74
  */
@@ -107,6 +113,29 @@ export class OpenAISTT {
107
113
  : DEFAULT_TIMEOUT_MS;
108
114
  this.defaultModel =
109
115
  slice.model ?? process.env.OPENAI_STT_MODEL ?? "whisper-1";
116
+ const priorForm = (slice.priorLanguageForm ??
117
+ process.env.OPENAI_STT_PRIOR_LANGUAGE_FORM ??
118
+ "").trim();
119
+ this.priorLanguageForm =
120
+ priorForm.includes("{lang}") && !this.isOfficialEndpoint
121
+ ? priorForm
122
+ : null;
123
+ const lid = (slice.lidModel ??
124
+ process.env.OPENAI_STT_LID_MODEL ??
125
+ "").trim();
126
+ this.lidModel = lid && !this.isOfficialEndpoint ? lid : null;
127
+ const vocabEnv = process.env.OPENAI_STT_VOCABULARY_PROMPT?.trim().toLowerCase();
128
+ this.vocabularyPrompt =
129
+ slice.vocabularyPrompt ??
130
+ (vocabEnv === undefined || vocabEnv === ""
131
+ ? true
132
+ : !(vocabEnv === "0" ||
133
+ vocabEnv === "false" ||
134
+ vocabEnv === "off" ||
135
+ vocabEnv === "no"));
136
+ }
137
+ languageProbeModel() {
138
+ return this.lidModel ?? undefined;
110
139
  }
111
140
  /** True when pointed at OpenAI itself rather than a compatible server. */
112
141
  get isOfficialEndpoint() {
@@ -238,10 +267,18 @@ export class OpenAISTT {
238
267
  // Add optional parameters. No language (or "auto") leaves detection to
239
268
  // the server; sending a guess would pin every request to it.
240
269
  const language = options.language?.trim();
270
+ const prior = options.languagePrior?.trim();
241
271
  if (language && language.toLowerCase() !== "auto") {
242
272
  formData.append("language", language);
243
273
  }
244
- const prompt = OpenAISTT.buildPrompt(whisperOptions.prompt, options.vocabulary);
274
+ else if (prior &&
275
+ this.priorLanguageForm &&
276
+ /^[a-z]{2,3}$/i.test(prior)) {
277
+ // Detection stays on; the server is told what it heard last time, in
278
+ // the form it documented. Never sent to a server without that form.
279
+ formData.append("language", this.priorLanguageForm.replace("{lang}", prior.toLowerCase()));
280
+ }
281
+ const prompt = OpenAISTT.buildPrompt(whisperOptions.prompt, this.vocabularyPrompt ? options.vocabulary : undefined);
245
282
  if (prompt) {
246
283
  formData.append("prompt", prompt);
247
284
  }
@@ -407,19 +444,33 @@ export class OpenAISTT {
407
444
  /**
408
445
  * Optional language-identification fields a self-hosted server may add to
409
446
  * `verbose_json`. Each is read only when it has the expected shape; a
410
- * server that sends none (OpenAI itself) contributes nothing.
447
+ * server that sends none (OpenAI itself) contributes nothing. The top-level
448
+ * fields win; when a gateway has stripped them (LiteLLM rewrites the top
449
+ * level to OpenAI's schema but keeps extra keys inside segments), the same
450
+ * fields on the first segment are read instead.
411
451
  */
412
452
  static readLanguageExtensions(data) {
413
453
  const out = {};
414
- if (typeof data.language_detected === "boolean") {
415
- out.languageDetected = data.language_detected;
454
+ const first = Array.isArray(data.segments) ? data.segments[0] : undefined;
455
+ const detected = typeof data.language_detected === "boolean"
456
+ ? data.language_detected
457
+ : first && typeof first.language_detected === "boolean"
458
+ ? first.language_detected
459
+ : undefined;
460
+ if (typeof detected === "boolean") {
461
+ out.languageDetected = detected;
416
462
  }
417
463
  if (typeof data.language_confidence === "number" &&
418
464
  Number.isFinite(data.language_confidence)) {
419
465
  out.languageConfidence = data.language_confidence;
420
466
  }
421
- if (Array.isArray(data.language_scores)) {
422
- const scores = data.language_scores.filter((entry) => typeof entry === "object" &&
467
+ const rawScores = Array.isArray(data.language_scores)
468
+ ? data.language_scores
469
+ : first && Array.isArray(first.language_scores)
470
+ ? first.language_scores
471
+ : undefined;
472
+ if (Array.isArray(rawScores)) {
473
+ const scores = rawScores.filter((entry) => typeof entry === "object" &&
423
474
  entry !== null &&
424
475
  typeof entry.language === "string" &&
425
476
  typeof entry.score === "number" &&
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Transcription sessions: one call, several speakers, every listener in the
3
+ * languages they chose.
4
+ *
5
+ * Each speaker is its own `transcribeStream()` (a call platform gives one
6
+ * track per participant). The session's output languages are the union of
7
+ * what its listeners read right now, read once per utterance, so ten
8
+ * listeners in Spanish cost one rendering and a language nobody reads costs
9
+ * nothing. A listener receives the transcript events of the speakers it
10
+ * follows and only the `output` events of its own languages.
11
+ *
12
+ * Late joiners get the backlog: the last `backlog` utterances, with every
13
+ * rendering that was made for them. A language added later applies from the
14
+ * next utterance on.
15
+ *
16
+ * @module voice/session/transcriptionSession
17
+ */
18
+ import type { NeuroLink } from "../../neurolink.js";
19
+ import type { TranscribeStreamOptions, TranscriptionSessionEvent, TranscriptionSessionOptions, TranscriptionSubscriberOptions, TranscriptionSubscription } from "../../types/index.js";
20
+ export declare class TranscriptionSession {
21
+ private readonly neurolink;
22
+ private readonly options;
23
+ readonly id: string;
24
+ private readonly subscribers;
25
+ /** Settled events per `speaker:utterance`, oldest group first. */
26
+ private readonly backlog;
27
+ private readonly backlogSize;
28
+ private readonly maxLanguages;
29
+ private readonly speakers;
30
+ private _unnamed;
31
+ private closed;
32
+ constructor(neurolink: Pick<NeuroLink, "transcribeStream">, options?: TranscriptionSessionOptions);
33
+ /** The output languages listeners read now, in order of first request, capped. */
34
+ languages(): string[];
35
+ /** Speakers whose audio is being transcribed now. */
36
+ activeSpeakers(): string[];
37
+ get listenerCount(): number;
38
+ get isClosed(): boolean;
39
+ /** A fallback name no speaker in this session holds (a counter, so a leaver's name is not reused). */
40
+ private _unnamedSpeaker;
41
+ /**
42
+ * Transcribe one speaker until its audio ends (or the session closes).
43
+ * Every event goes to the session's listeners; `onEvent`, when given, sees
44
+ * them too (the speaker's own captions). The speaker's own
45
+ * `output.languages`, if any, are rendered in addition to the listeners'.
46
+ */
47
+ addSpeaker(speaker: string, options: TranscribeStreamOptions, onEvent?: (event: TranscriptionSessionEvent) => void): Promise<void>;
48
+ /** Follow the session. With `backlog` (the default), the last utterances are replayed first. */
49
+ subscribe(options: TranscriptionSubscriberOptions, onEvent: (event: TranscriptionSessionEvent) => void): TranscriptionSubscription;
50
+ /** Stop delivering; speakers' streams end at their next event. */
51
+ close(): void;
52
+ private remember;
53
+ private broadcast;
54
+ private safeDeliver;
55
+ }