@juspay/neurolink 13.6.5 → 13.8.0
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.
- package/CHANGELOG.md +6 -2
- package/dist/auth/sessionManager.js +8 -3
- package/dist/browser/neurolink.min.js +480 -460
- package/dist/cli/commands/serve.js +11 -1
- package/dist/cli/commands/transcribe.js +39 -3
- package/dist/constants/langfuse.d.ts +2 -0
- package/dist/constants/langfuse.js +2 -0
- package/dist/core/redisConversationMemoryManager.js +6 -16
- package/dist/factories/sttDescriptors.js +7 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +4 -2
- package/dist/neurolink.js +33 -1
- package/dist/observability/exporters/langfuseExporter.d.ts +4 -0
- package/dist/observability/exporters/langfuseExporter.js +29 -8
- package/dist/observability/otlpEncoding.js +2 -2
- package/dist/server/abstract/baseServerAdapter.js +2 -0
- package/dist/server/adapters/expressAdapter.js +1 -1
- package/dist/server/adapters/fastifyAdapter.js +4 -1
- package/dist/server/adapters/honoAdapter.js +1 -1
- package/dist/server/adapters/koaAdapter.js +1 -1
- package/dist/server/routes/transcribeRoutes.d.ts +0 -8
- package/dist/server/routes/transcribeRoutes.js +130 -3
- package/dist/server/utils/validation.d.ts +409 -15
- package/dist/server/utils/validation.js +106 -4
- package/dist/server/websocket/frameQueue.d.ts +16 -0
- package/dist/server/websocket/frameQueue.js +55 -0
- package/dist/server/websocket/realtimeTranscription.d.ts +48 -0
- package/dist/server/websocket/realtimeTranscription.js +603 -0
- package/dist/server/websocket/transcribeWebSocket.d.ts +17 -1
- package/dist/server/websocket/transcribeWebSocket.js +263 -64
- package/dist/services/server/ai/observability/langfuseSetup.js +6 -1
- package/dist/telemetry/attributes.d.ts +1 -1
- package/dist/telemetry/attributes.js +84 -8
- package/dist/types/cli.d.ts +6 -0
- package/dist/types/exporter.d.ts +3 -1
- package/dist/types/server.d.ts +304 -2
- package/dist/types/stt.d.ts +38 -3
- package/dist/types/transcribe.d.ts +401 -5
- package/dist/utils/redis.d.ts +17 -0
- package/dist/utils/redis.js +31 -0
- package/dist/voice/audioLevel.d.ts +14 -0
- package/dist/voice/audioLevel.js +60 -0
- package/dist/voice/codeMix.d.ts +40 -0
- package/dist/voice/codeMix.js +282 -0
- package/dist/voice/correction/align.d.ts +12 -0
- package/dist/voice/correction/align.js +48 -0
- package/dist/voice/correction/guards.d.ts +39 -0
- package/dist/voice/correction/guards.js +122 -0
- package/dist/voice/correction/index.d.ts +25 -3
- package/dist/voice/correction/index.js +132 -4
- package/dist/voice/correction/rewrite.d.ts +64 -3
- package/dist/voice/correction/rewrite.js +295 -25
- package/dist/voice/eval/sttEvalLangfuse.d.ts +14 -0
- package/dist/voice/eval/sttEvalLangfuse.js +182 -0
- package/dist/voice/eval/sttEvalStore.d.ts +27 -0
- package/dist/voice/eval/sttEvalStore.js +397 -0
- package/dist/voice/index.js +3 -0
- package/dist/voice/languages.d.ts +34 -0
- package/dist/voice/languages.js +290 -0
- package/dist/voice/modelAssets.d.ts +28 -0
- package/dist/voice/modelAssets.js +113 -0
- package/dist/voice/output/render.d.ts +32 -0
- package/dist/voice/output/render.js +368 -0
- package/dist/voice/providers/OpenAISTT.d.ts +11 -1
- package/dist/voice/providers/OpenAISTT.js +57 -6
- package/dist/voice/session/transcriptionSession.d.ts +55 -0
- package/dist/voice/session/transcriptionSession.js +225 -0
- package/dist/voice/streaming/chunkedStreamAdapter.d.ts +7 -0
- package/dist/voice/streaming/chunkedStreamAdapter.js +711 -28
- package/dist/voice/streaming/endpointer.d.ts +50 -17
- package/dist/voice/streaming/endpointer.js +115 -19
- package/dist/voice/transcribe.d.ts +18 -1
- package/dist/voice/transcribe.js +459 -18
- package/dist/voice/transcribeStream.d.ts +18 -1
- package/dist/voice/transcribeStream.js +413 -16
- package/dist/voice/vad/assets.d.ts +36 -0
- package/dist/voice/vad/assets.js +142 -0
- package/dist/voice/vad/frameScorer.d.ts +28 -0
- package/dist/voice/vad/frameScorer.js +90 -0
- package/dist/voice/vad/sileroVad.d.ts +43 -0
- package/dist/voice/vad/sileroVad.js +178 -0
- package/dist/voice/whistle/assets.d.ts +3 -4
- package/dist/voice/whistle/assets.js +11 -95
- package/docs-site/static/search-index.json +21 -6
- package/models/silero-vad/README.md +19 -0
- package/models/silero-vad/silero_vad.onnx +0 -0
- package/package.json +4 -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
|
-
|
|
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
|
-
|
|
415
|
-
|
|
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
|
-
|
|
422
|
-
|
|
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
|
+
}
|