nixamp 0.23.7 → 0.24.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/README.md +79 -3
- package/dist/captions.d.ts +57 -10
- package/dist/captions.js +253 -29
- package/dist/main.js +63 -19
- package/dist/mcp.d.ts +7 -1
- package/dist/mcp.js +159 -24
- package/dist/server.d.ts +11 -0
- package/dist/server.js +353 -8
- package/dist/speech.d.ts +21 -3
- package/dist/speech.js +65 -8
- package/dist/transcribe.d.ts +70 -0
- package/dist/transcribe.js +337 -41
- package/dist/transcript-client.d.ts +81 -0
- package/dist/transcript-client.js +76 -0
- package/dist/transcript.d.ts +7 -2
- package/dist/transcript.js +73 -5
- package/dist/transcripts.d.ts +135 -0
- package/dist/transcripts.js +384 -0
- package/dist/translate-cli.d.ts +16 -0
- package/dist/translate-cli.js +97 -0
- package/dist/translate-jobs.d.ts +39 -0
- package/dist/translate-jobs.js +120 -0
- package/dist/translate.d.ts +78 -0
- package/dist/translate.js +241 -0
- package/dist/warm.d.ts +1 -0
- package/dist/warm.js +40 -0
- package/package.json +1 -1
- package/src/captions.ts +276 -29
- package/src/main.ts +63 -19
- package/src/mcp.ts +156 -21
- package/src/server.ts +352 -8
- package/src/speech.ts +103 -13
- package/src/transcribe.ts +374 -41
- package/src/transcript-client.ts +147 -0
- package/src/transcript.ts +76 -4
- package/src/transcripts.ts +462 -0
- package/src/translate-cli.ts +111 -0
- package/src/translate-jobs.ts +132 -0
- package/src/translate.ts +276 -0
- package/src/warm.ts +43 -0
- package/web/dist/assets/{hls-3VKVEQE3-CI1U7kbP.js → hls-3VKVEQE3-Dtl-3mpW.js} +1 -1
- package/web/dist/assets/index-B0h4Nexr.js +1 -0
- package/web/dist/assets/index-BTGV3Pi5.css +1 -0
- package/web/dist/assets/{mpegts-CPOYjgRP.js → mpegts-BJC48bFV.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CE8YPjx1.js → mpegts-LO6RVLD6-CUIAB9k3.js} +1 -1
- package/web/dist/index.html +12 -8
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-46pwGn5-.css +0 -1
- package/web/dist/assets/index-5H3sUGHu.js +0 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nixamp translate` -- a line in another language, from the terminal.
|
|
3
|
+
*
|
|
4
|
+
* The models are nixamp.com's (see translate.ts); this sends the text and
|
|
5
|
+
* prints what comes back, signed in as whoever this machine is signed in
|
|
6
|
+
* as. Text on the command line, or on stdin when there is none there, one
|
|
7
|
+
* line at a time so a file of lines comes back as a file of lines.
|
|
8
|
+
*/
|
|
9
|
+
import { readSession, type Session } from "./session.ts";
|
|
10
|
+
import { translateTexts, type Signed } from "./transcript-client.ts";
|
|
11
|
+
import { languageCode } from "./transcripts.ts";
|
|
12
|
+
|
|
13
|
+
const HELP = `nixamp translate — say it in another language.
|
|
14
|
+
|
|
15
|
+
nixamp translate --to sv "Hello there" Swedish, from English
|
|
16
|
+
nixamp translate --from de --to en "Guten Tag"
|
|
17
|
+
cat lines.txt | nixamp translate --to de each line, in order
|
|
18
|
+
nixamp translate --languages what nixamp.com can translate between
|
|
19
|
+
|
|
20
|
+
The models are open-source (OPUS-MT) and run on nixamp.com's own CPU; a pair
|
|
21
|
+
with no model of its own goes through English. Needs a sign-in
|
|
22
|
+
(\`nixamp login\`).
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
function flag(argv: string[], name: string): string | undefined {
|
|
26
|
+
const at = argv.indexOf(name);
|
|
27
|
+
return at === -1 ? undefined : argv[at + 1];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TranslateDeps {
|
|
31
|
+
fetcher?: typeof fetch;
|
|
32
|
+
session?: Pick<Session, "site" | "token"> | null;
|
|
33
|
+
/** What stdin says, when no text is on the command line; the tests hand it in. */
|
|
34
|
+
stdin?: () => Promise<string>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function readStdin(): Promise<string> {
|
|
38
|
+
const chunks: Buffer[] = [];
|
|
39
|
+
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
|
|
40
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface LanguageRow {
|
|
44
|
+
code: string;
|
|
45
|
+
name: string;
|
|
46
|
+
native: string;
|
|
47
|
+
targets: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function translate(argv: string[], deps: TranslateDeps = {}): Promise<number> {
|
|
51
|
+
if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
|
|
52
|
+
console.log(HELP);
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
const session = deps.session === undefined ? readSession() : deps.session;
|
|
56
|
+
if (session === null) {
|
|
57
|
+
console.error("nixamp: not signed in. Try `nixamp login`.");
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
const fetcher = deps.fetcher ?? fetch;
|
|
61
|
+
const signed: Signed = { site: session.site, token: session.token };
|
|
62
|
+
if (argv.includes("--languages")) {
|
|
63
|
+
let response: Response;
|
|
64
|
+
try {
|
|
65
|
+
response = await fetcher(`${signed.site.replace(/\/+$/, "")}/api/v1/translate`);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error(`nixamp: could not reach ${signed.site}: ${(error as Error).message}`);
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
70
|
+
const body = (await response.json().catch(() => ({}))) as { available?: boolean; languages?: LanguageRow[]; error?: string };
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
console.error(`nixamp: ${body.error ?? `nixamp.com answered ${response.status}`}`);
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
if (argv.includes("--json")) {
|
|
76
|
+
console.log(JSON.stringify(body, null, 2));
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
if (body.available === false) console.error("nixamp: that nixamp has no translation models; nixamp.com does.");
|
|
80
|
+
for (const language of body.languages ?? []) {
|
|
81
|
+
console.log(`${language.code} ${language.name.padEnd(11)} ${language.native.padEnd(17)} -> ${language.targets.join(" ")}`);
|
|
82
|
+
}
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
const to = languageCode(flag(argv, "--to"));
|
|
86
|
+
const from = languageCode(flag(argv, "--from") ?? "en");
|
|
87
|
+
if (!to || from === null || from === "") {
|
|
88
|
+
console.error("nixamp: --to (and --from, English by default) are two-letter language codes, such as sv.");
|
|
89
|
+
return 64;
|
|
90
|
+
}
|
|
91
|
+
const withValue = new Set(["--to", "--from"]);
|
|
92
|
+
const given = argv.filter((one, at) => !one.startsWith("-") && !(at > 0 && withValue.has(argv[at - 1] as string)));
|
|
93
|
+
const text = given.length > 0 ? given.join(" ") : await (deps.stdin ?? readStdin)();
|
|
94
|
+
const lines = text.split(/\r?\n/).map((line) => line.trimEnd());
|
|
95
|
+
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
96
|
+
if (lines.length === 0) {
|
|
97
|
+
console.error("nixamp: translate what? Pass the text, or pipe it in.");
|
|
98
|
+
return 64;
|
|
99
|
+
}
|
|
100
|
+
const got = await translateTexts(signed, lines, from, to, fetcher);
|
|
101
|
+
if (!got.ok) {
|
|
102
|
+
console.error(`nixamp: ${got.error}`);
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
if (argv.includes("--json")) {
|
|
106
|
+
console.log(JSON.stringify(got.body, null, 2));
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
console.log(got.body.texts.join("\n"));
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A stored transcript, translated: the job that does it, and its progress.
|
|
3
|
+
*
|
|
4
|
+
* A film's transcript is more than a thousand lines, and a thousand lines
|
|
5
|
+
* through a Marian model is minutes of CPU. No request waits that long.
|
|
6
|
+
* The first ask for a language starts the job and answers 202 with what
|
|
7
|
+
* there is; the job keeps the store up as it goes, so a second ask sees
|
|
8
|
+
* more, and a process that dies mid-way leaves behind what it had done
|
|
9
|
+
* for the next one to carry on from -- only the lines the store has no
|
|
10
|
+
* translation of yet are sent to the model. When the translation is as
|
|
11
|
+
* far along as the original, the ask is answered 200 with it.
|
|
12
|
+
*
|
|
13
|
+
* One job per transcript and language at a time; asking again joins the
|
|
14
|
+
* job that is running rather than starting another.
|
|
15
|
+
*/
|
|
16
|
+
import { SpeechError } from "./speech.ts";
|
|
17
|
+
import { BATCH, type Translator } from "./translate.ts";
|
|
18
|
+
import { lineAt, type Transcript, type TranscriptLine, type Transcripts } from "./transcripts.ts";
|
|
19
|
+
|
|
20
|
+
export interface Progress {
|
|
21
|
+
done: number;
|
|
22
|
+
total: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Job {
|
|
26
|
+
progress: Progress;
|
|
27
|
+
finished: Promise<void>;
|
|
28
|
+
error: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What a request should answer: the status and the body's extra fields. */
|
|
32
|
+
export type Answer =
|
|
33
|
+
| { status: 200; transcript: Transcript }
|
|
34
|
+
| { status: 202; transcript: Transcript | null; translating: Progress }
|
|
35
|
+
| { status: 404 | 409 | 503; error: string };
|
|
36
|
+
|
|
37
|
+
export class StoredTranslations {
|
|
38
|
+
private readonly jobs = new Map<string, Job>();
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
private readonly store: Transcripts,
|
|
42
|
+
private readonly translator: Translator | undefined,
|
|
43
|
+
private readonly onEvent: (message: string) => void = () => {},
|
|
44
|
+
) {}
|
|
45
|
+
|
|
46
|
+
/** The lines of the original that the translation does not yet have. */
|
|
47
|
+
static missing(original: Transcript, translation: Transcript | null): TranscriptLine[] {
|
|
48
|
+
if (!translation) return original.lines;
|
|
49
|
+
return original.lines.filter((line) => lineAt(translation.lines, line.start) === null);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The transcript in a language, or the job making it. The original when
|
|
54
|
+
* the language is the one it was heard in.
|
|
55
|
+
*/
|
|
56
|
+
async get(id: string, language: string, by: string): Promise<Answer> {
|
|
57
|
+
const original = await this.store.get(id);
|
|
58
|
+
if (!original) return { status: 404, error: "nothing has been written down for that" };
|
|
59
|
+
if (language === "" || language === original.language) return { status: 200, transcript: original };
|
|
60
|
+
const translation = await this.store.get(id, language);
|
|
61
|
+
const missing = StoredTranslations.missing(original, translation);
|
|
62
|
+
if (translation && missing.length === 0) return { status: 200, transcript: translation };
|
|
63
|
+
if (original.language === "") return { status: 409, error: "the language this was heard in is not known, so it cannot be translated" };
|
|
64
|
+
if (!this.translator) return { status: 503, error: "this nixamp cannot translate: no model here. nixamp.com can." };
|
|
65
|
+
if (!this.translator.can(original.language, language)) {
|
|
66
|
+
return { status: 409, error: `there is no model here from ${original.language} to ${language}` };
|
|
67
|
+
}
|
|
68
|
+
const key = `${id}|${language}`;
|
|
69
|
+
let job = this.jobs.get(key);
|
|
70
|
+
if (job?.error) {
|
|
71
|
+
this.jobs.delete(key);
|
|
72
|
+
return { status: 503, error: job.error };
|
|
73
|
+
}
|
|
74
|
+
if (!job) {
|
|
75
|
+
job = this.start(key, original, translation, missing, language, by);
|
|
76
|
+
// A short one is done before the ask is answered.
|
|
77
|
+
if (missing.length <= BATCH) {
|
|
78
|
+
await job.finished;
|
|
79
|
+
if (job.error) {
|
|
80
|
+
this.jobs.delete(key);
|
|
81
|
+
return { status: 503, error: job.error };
|
|
82
|
+
}
|
|
83
|
+
const made = await this.store.get(id, language);
|
|
84
|
+
if (made) return { status: 200, transcript: made };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { status: 202, transcript: translation, translating: { ...job.progress } };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private start(key: string, original: Transcript, translation: Transcript | null, missing: TranscriptLine[], language: string, by: string): Job {
|
|
91
|
+
const job: Job = { progress: { done: original.lines.length - missing.length, total: original.lines.length }, finished: Promise.resolve(), error: "" };
|
|
92
|
+
job.finished = this.run(original, translation, missing, language, by, job)
|
|
93
|
+
.catch((error: unknown) => {
|
|
94
|
+
job.error = error instanceof SpeechError ? error.message : `translating failed: ${(error as Error).message}`;
|
|
95
|
+
this.onEvent(` translating ${original.title || original.media} to ${language}: ${job.error}`);
|
|
96
|
+
})
|
|
97
|
+
.finally(() => {
|
|
98
|
+
if (!job.error) this.jobs.delete(key);
|
|
99
|
+
});
|
|
100
|
+
this.jobs.set(key, job);
|
|
101
|
+
return job;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private async run(original: Transcript, translation: Transcript | null, missing: TranscriptLine[], language: string, by: string, job: Job): Promise<void> {
|
|
105
|
+
const translator = this.translator as Translator;
|
|
106
|
+
const made: TranscriptLine[] = translation ? [...translation.lines] : [];
|
|
107
|
+
let model = "";
|
|
108
|
+
for (let at = 0; at < missing.length; at += BATCH) {
|
|
109
|
+
const batch = missing.slice(at, at + BATCH);
|
|
110
|
+
const done = await translator.translate(batch.map((line) => line.text), original.language, language);
|
|
111
|
+
model = done.model;
|
|
112
|
+
const lines = batch.map((line, i) => ({ start: line.start, end: line.end, text: done.texts[i] ?? "" })).filter((line) => line.text !== "");
|
|
113
|
+
made.push(...lines);
|
|
114
|
+
await this.store.save({
|
|
115
|
+
media: original.media, language, translatedFrom: original.language, model, title: original.title, by, lines,
|
|
116
|
+
});
|
|
117
|
+
job.progress.done += batch.length;
|
|
118
|
+
}
|
|
119
|
+
if (original.complete) {
|
|
120
|
+
// Whole, like the original: the pieces are replaced by the lot, and nothing partial touches it again.
|
|
121
|
+
await this.store.save({
|
|
122
|
+
media: original.media, language, translatedFrom: original.language, model, title: original.title, by, lines: made, complete: true,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (missing.length > 0) this.onEvent(` translated ${missing.length} lines of ${original.title || original.media} to ${language}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** What is being translated right now. */
|
|
129
|
+
running(): { key: string; progress: Progress }[] {
|
|
130
|
+
return [...this.jobs.entries()].filter(([, job]) => !job.error).map(([key, job]) => ({ key, progress: { ...job.progress } }));
|
|
131
|
+
}
|
|
132
|
+
}
|
package/src/translate.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translation: a line in one language, said in another, on this CPU.
|
|
3
|
+
*
|
|
4
|
+
* The models are the Helsinki-NLP OPUS-MT pairs, one small Marian model
|
|
5
|
+
* per direction, run through Transformers.js like the ear (speech.ts):
|
|
6
|
+
* open weights, nothing sent anywhere, nothing billed. A pair is about a
|
|
7
|
+
* hundred megabytes and a sentence takes about a second; the many-language
|
|
8
|
+
* models that could do any pair in one model were measured and were not
|
|
9
|
+
* worth it -- NLLB-200 wants two gigabytes of memory and ten to twenty
|
|
10
|
+
* seconds a sentence here, and the multi-target Marian models echo their
|
|
11
|
+
* target prefix back and translate nothing. So a pair that has no model of
|
|
12
|
+
* its own goes through English: Swedish to German is Swedish to English,
|
|
13
|
+
* then English to German, twice the wait and the cost of the pivot in
|
|
14
|
+
* meaning, and still the honest choice.
|
|
15
|
+
*
|
|
16
|
+
* Pairs are loaded when first asked for and the least recently used is
|
|
17
|
+
* let go when there are more than a few, because each loaded pair holds a
|
|
18
|
+
* few hundred megabytes and a deployment has a ceiling. Batches are kept
|
|
19
|
+
* to a handful of lines with a cap on how much they may say: a Marian
|
|
20
|
+
* model handed twenty lines at once ran one of them into a wall of full
|
|
21
|
+
* stops until it hit the limit.
|
|
22
|
+
*/
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { stateDir } from "./daemon.ts";
|
|
25
|
+
import { SpeechError, loadTransformers } from "./speech.ts";
|
|
26
|
+
|
|
27
|
+
/** Where the pair models come from: this prefix and `<from>-<to>`. */
|
|
28
|
+
export const MODEL_PREFIX = "Xenova/opus-mt-";
|
|
29
|
+
/** How many pairs stay loaded at once. Each holds three to five hundred megabytes beside the ear's four. */
|
|
30
|
+
export const KEEP_LOADED = 3;
|
|
31
|
+
/** How many lines a model is handed at a time. Past eight one of them may run away. */
|
|
32
|
+
export const BATCH = 8;
|
|
33
|
+
/** How much one account may have translated in a minute, in characters. Twelve captions a minute is under a thousand. */
|
|
34
|
+
export const CHARS_PER_MINUTE = 20_000;
|
|
35
|
+
/** How many may wait on the CPU at once. */
|
|
36
|
+
export const QUEUE_LIMIT = 16;
|
|
37
|
+
/** A translation is never longer than this many tokens: the runaway guard. */
|
|
38
|
+
export const MAX_NEW_TOKENS = 160;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The directions a pair model exists for, as of the models that were
|
|
42
|
+
* measured. Anything else goes through English, and what English cannot
|
|
43
|
+
* reach is refused with a sentence rather than guessed at.
|
|
44
|
+
*/
|
|
45
|
+
export const PAIRS: ReadonlySet<string> = new Set([
|
|
46
|
+
// Each of these answered 200 from the hub on 2026-09-13; de-sv, sv-de, de-it, de-nl and the Greek pairs did not.
|
|
47
|
+
"en-de", "en-sv", "en-es", "en-fr", "en-it", "en-nl", "en-ru", "en-zh", "en-ar", "en-fi", "en-da", "en-hu", "en-cs", "en-uk", "en-vi", "en-id", "en-hi",
|
|
48
|
+
"de-en", "sv-en", "es-en", "fr-en", "it-en", "nl-en", "pl-en", "ru-en", "ja-en", "zh-en", "ko-en", "ar-en", "tr-en", "fi-en", "da-en", "hu-en", "cs-en", "uk-en", "vi-en", "id-en", "hi-en",
|
|
49
|
+
"de-fr", "fr-de", "de-es", "es-de", "fr-es", "es-fr",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/** What the page and the CLI call a language. */
|
|
53
|
+
export const LANGUAGES: Record<string, { name: string; native: string }> = {
|
|
54
|
+
en: { name: "English", native: "English" },
|
|
55
|
+
de: { name: "German", native: "Deutsch" },
|
|
56
|
+
sv: { name: "Swedish", native: "Svenska" },
|
|
57
|
+
es: { name: "Spanish", native: "Español" },
|
|
58
|
+
fr: { name: "French", native: "Français" },
|
|
59
|
+
it: { name: "Italian", native: "Italiano" },
|
|
60
|
+
nl: { name: "Dutch", native: "Nederlands" },
|
|
61
|
+
pl: { name: "Polish", native: "Polski" },
|
|
62
|
+
ru: { name: "Russian", native: "Русский" },
|
|
63
|
+
ja: { name: "Japanese", native: "日本語" },
|
|
64
|
+
zh: { name: "Chinese", native: "中文" },
|
|
65
|
+
ko: { name: "Korean", native: "한국어" },
|
|
66
|
+
ar: { name: "Arabic", native: "العربية" },
|
|
67
|
+
tr: { name: "Turkish", native: "Türkçe" },
|
|
68
|
+
fi: { name: "Finnish", native: "Suomi" },
|
|
69
|
+
da: { name: "Danish", native: "Dansk" },
|
|
70
|
+
hu: { name: "Hungarian", native: "Magyar" },
|
|
71
|
+
cs: { name: "Czech", native: "Čeština" },
|
|
72
|
+
uk: { name: "Ukrainian", native: "Українська" },
|
|
73
|
+
vi: { name: "Vietnamese", native: "Tiếng Việt" },
|
|
74
|
+
id: { name: "Indonesian", native: "Bahasa Indonesia" },
|
|
75
|
+
hi: { name: "Hindi", native: "हिन्दी" },
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** The models a translation from one language to another goes through, or null when there is no way. */
|
|
79
|
+
export function route(from: string, to: string, pairs: ReadonlySet<string> = PAIRS): [string, string][] | null {
|
|
80
|
+
if (from === to) return [];
|
|
81
|
+
if (pairs.has(`${from}-${to}`)) return [[from, to]];
|
|
82
|
+
if (from !== "en" && to !== "en" && pairs.has(`${from}-en`) && pairs.has(`en-${to}`)) return [[from, "en"], ["en", to]];
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function modelFor(from: string, to: string): string {
|
|
87
|
+
return `${MODEL_PREFIX}${from}-${to}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Lines in, lines out, one model. What a loaded pair is, to this file. */
|
|
91
|
+
export type Pair = {
|
|
92
|
+
translate: (texts: string[]) => Promise<string[]>;
|
|
93
|
+
dispose?: () => Promise<void> | void;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export interface TranslatorOptions {
|
|
97
|
+
cacheDir?: string;
|
|
98
|
+
/** How a pair is loaded. The tests hand in a fake; nothing else does. */
|
|
99
|
+
load?: (model: string, cacheDir: string) => Promise<Pair>;
|
|
100
|
+
now?: () => number;
|
|
101
|
+
keep?: number;
|
|
102
|
+
pairs?: ReadonlySet<string>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface Translated {
|
|
106
|
+
texts: string[];
|
|
107
|
+
from: string;
|
|
108
|
+
to: string;
|
|
109
|
+
/** The model, or the two, that did it. */
|
|
110
|
+
model: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface TranslationPipeline {
|
|
114
|
+
(texts: string[], options: Record<string, unknown>): Promise<{ translation_text: string }[]>;
|
|
115
|
+
dispose?: () => Promise<void>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface Transformers {
|
|
119
|
+
env: { cacheDir?: string };
|
|
120
|
+
pipeline(task: "translation", model: string, options: { dtype: string }): Promise<TranslationPipeline>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function loadMarian(model: string, cacheDir: string): Promise<Pair> {
|
|
124
|
+
const transformers = await loadTransformers<Transformers>();
|
|
125
|
+
transformers.env.cacheDir = cacheDir;
|
|
126
|
+
const pipeline = await transformers.pipeline("translation", model, { dtype: "q8" });
|
|
127
|
+
return {
|
|
128
|
+
translate: async (texts) => {
|
|
129
|
+
const out: string[] = [];
|
|
130
|
+
for (let i = 0; i < texts.length; i += BATCH) {
|
|
131
|
+
const batch = texts.slice(i, i + BATCH);
|
|
132
|
+
// Twice the input and a little: a translation is about as long as
|
|
133
|
+
// what it translates, and a model that keeps going has gone wrong.
|
|
134
|
+
const longest = Math.max(...batch.map((text) => text.length));
|
|
135
|
+
const answers = await pipeline(batch, { max_new_tokens: Math.min(MAX_NEW_TOKENS, Math.ceil(longest / 2) + 16) });
|
|
136
|
+
out.push(...answers.map((answer) => answer.translation_text));
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
},
|
|
140
|
+
dispose: () => pipeline.dispose?.(),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export class Translator {
|
|
145
|
+
private readonly cacheDir: string;
|
|
146
|
+
private readonly load: (model: string, cacheDir: string) => Promise<Pair>;
|
|
147
|
+
private readonly now: () => number;
|
|
148
|
+
private readonly keep: number;
|
|
149
|
+
private readonly pairs: ReadonlySet<string>;
|
|
150
|
+
private readonly loaded = new Map<string, { pair: Promise<Pair>; usedAt: number }>();
|
|
151
|
+
private tail: Promise<unknown> = Promise.resolve();
|
|
152
|
+
private waiting = 0;
|
|
153
|
+
private readonly asked = new Map<string, { minute: number; chars: number }>();
|
|
154
|
+
lastFailure = "";
|
|
155
|
+
|
|
156
|
+
constructor(options: TranslatorOptions = {}) {
|
|
157
|
+
this.cacheDir = options.cacheDir ?? process.env["NIXAMP_STT_CACHE"] ?? join(stateDir(), "models");
|
|
158
|
+
this.load = options.load ?? loadMarian;
|
|
159
|
+
this.now = options.now ?? (() => Date.now());
|
|
160
|
+
this.keep = options.keep ?? KEEP_LOADED;
|
|
161
|
+
this.pairs = options.pairs ?? PAIRS;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Whether one language can be said in another here, at all. */
|
|
165
|
+
can(from: string, to: string): boolean {
|
|
166
|
+
return route(from, to, this.pairs) !== null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The languages something in `from` can be translated into. */
|
|
170
|
+
targets(from: string): string[] {
|
|
171
|
+
return Object.keys(LANGUAGES).filter((to) => to !== from && this.can(from, to));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private pair(from: string, to: string): Promise<Pair> {
|
|
175
|
+
const model = modelFor(from, to);
|
|
176
|
+
const held = this.loaded.get(model);
|
|
177
|
+
if (held) {
|
|
178
|
+
held.usedAt = this.now();
|
|
179
|
+
return held.pair;
|
|
180
|
+
}
|
|
181
|
+
const pair = this.load(model, this.cacheDir).catch((error: unknown) => {
|
|
182
|
+
// A failed load is tried again next time, not remembered forever.
|
|
183
|
+
this.loaded.delete(model);
|
|
184
|
+
throw error;
|
|
185
|
+
});
|
|
186
|
+
this.loaded.set(model, { pair, usedAt: this.now() });
|
|
187
|
+
if (this.loaded.size > this.keep) {
|
|
188
|
+
let oldest: [string, { pair: Promise<Pair>; usedAt: number }] | null = null;
|
|
189
|
+
for (const entry of this.loaded) if (entry[0] !== model && (oldest === null || entry[1].usedAt < oldest[1].usedAt)) oldest = entry;
|
|
190
|
+
if (oldest) {
|
|
191
|
+
this.loaded.delete(oldest[0]);
|
|
192
|
+
void oldest[1].pair.then((old) => old.dispose?.()).catch(() => undefined);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return pair;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Load pairs now, so the first line is not the one that waits. Says whether every one of them could; never throws. */
|
|
199
|
+
async warm(pairs: string[]): Promise<boolean> {
|
|
200
|
+
let all = true;
|
|
201
|
+
for (const name of pairs) {
|
|
202
|
+
const [from, to] = name.split("-");
|
|
203
|
+
if (!from || !to) continue;
|
|
204
|
+
try {
|
|
205
|
+
await this.pair(from, to);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
this.lastFailure = (error as Error).message;
|
|
208
|
+
all = false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return all;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The pairs loaded right now, by model name. */
|
|
215
|
+
loadedModels(): string[] {
|
|
216
|
+
return [...this.loaded.keys()];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Whether this account may have this much translated now, and the bookkeeping if so. */
|
|
220
|
+
allow(accountId: string, chars: number): void {
|
|
221
|
+
const minute = Math.floor(this.now() / 60_000);
|
|
222
|
+
const record = this.asked.get(accountId) ?? { minute, chars: 0 };
|
|
223
|
+
if (record.minute !== minute) {
|
|
224
|
+
record.minute = minute;
|
|
225
|
+
record.chars = 0;
|
|
226
|
+
}
|
|
227
|
+
if (record.chars + chars > CHARS_PER_MINUTE) {
|
|
228
|
+
throw new SpeechError(`${CHARS_PER_MINUTE} characters a minute is plenty; try again in a moment`, 429);
|
|
229
|
+
}
|
|
230
|
+
record.chars += chars;
|
|
231
|
+
this.asked.set(accountId, record);
|
|
232
|
+
if (this.asked.size > 5000) {
|
|
233
|
+
for (const [id, one] of this.asked) if (one.minute !== minute) this.asked.delete(id);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Texts in another language. Refuses a pair nobody can do, too much for
|
|
239
|
+
* one account, or too many waiting, each with a status. Empty texts come
|
|
240
|
+
* back empty and cost nothing.
|
|
241
|
+
*/
|
|
242
|
+
async translate(texts: string[], from: string, to: string, options: { by?: string } = {}): Promise<Translated> {
|
|
243
|
+
const hops = route(from, to, this.pairs);
|
|
244
|
+
if (hops === null) {
|
|
245
|
+
const known = Object.keys(LANGUAGES).includes(from) && Object.keys(LANGUAGES).includes(to);
|
|
246
|
+
throw new SpeechError(known ? `there is no model here from ${from} to ${to}` : `translation is between two-letter language codes, such as en and de`, 400);
|
|
247
|
+
}
|
|
248
|
+
const chars = texts.reduce((sum, text) => sum + text.length, 0);
|
|
249
|
+
if (options.by) this.allow(options.by, chars);
|
|
250
|
+
const model = hops.map(([a, b]) => modelFor(a, b)).join(" then ");
|
|
251
|
+
if (hops.length === 0 || chars === 0) return { texts: [...texts], from, to, model };
|
|
252
|
+
if (this.waiting >= QUEUE_LIMIT) throw new SpeechError("too much is being translated at once; try again in a moment", 503);
|
|
253
|
+
this.waiting += 1;
|
|
254
|
+
const turn = this.tail.then(async () => {
|
|
255
|
+
// Only the lines with something in them go to the model; the rest keep their place.
|
|
256
|
+
const spoken = texts.map((text) => text.trim());
|
|
257
|
+
const which = spoken.map((text, i) => (text === "" ? -1 : i)).filter((i) => i >= 0);
|
|
258
|
+
let current = which.map((i) => spoken[i] as string);
|
|
259
|
+
for (const [a, b] of hops) {
|
|
260
|
+
const pair = await this.pair(a, b);
|
|
261
|
+
current = await pair.translate(current);
|
|
262
|
+
}
|
|
263
|
+
const out = [...spoken];
|
|
264
|
+
which.forEach((i, at) => {
|
|
265
|
+
out[i] = (current[at] ?? "").trim();
|
|
266
|
+
});
|
|
267
|
+
return out;
|
|
268
|
+
});
|
|
269
|
+
this.tail = turn.catch(() => undefined);
|
|
270
|
+
try {
|
|
271
|
+
return { texts: await turn, from, to, model };
|
|
272
|
+
} finally {
|
|
273
|
+
this.waiting -= 1;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
package/src/warm.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bun dist/warm.js` -- fetch the models now, so the image carries them.
|
|
3
|
+
*
|
|
4
|
+
* Run at build time by the Dockerfile. nixamp.com's filesystem is thrown
|
|
5
|
+
* away with every deploy, so without this the ear (80 MB) and every
|
|
6
|
+
* translation pair (about 100 MB each) were downloaded again at the first
|
|
7
|
+
* ask after each one, and the first person to speak waited for it. The
|
|
8
|
+
* models land in NIXAMP_STT_CACHE and the image keeps them.
|
|
9
|
+
*
|
|
10
|
+
* NIXAMP_MT_WARM names the pairs (en-de,en-sv,de-en,sv-en by default):
|
|
11
|
+
* German and Swedish both ways, which are the two that were asked for.
|
|
12
|
+
* Exits non-zero when anything could not be fetched, so a build does not
|
|
13
|
+
* quietly ship without its ear.
|
|
14
|
+
*/
|
|
15
|
+
import { Speech } from "./speech.ts";
|
|
16
|
+
import { Translator } from "./translate.ts";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_PAIRS = "en-de,en-sv,de-en,sv-en";
|
|
19
|
+
|
|
20
|
+
async function main(): Promise<number> {
|
|
21
|
+
const speech = new Speech();
|
|
22
|
+
console.error(`warming ${speech.model}...`);
|
|
23
|
+
if (!(await speech.warm())) {
|
|
24
|
+
console.error(`could not load the ear: ${speech.lastFailure}`);
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
const pairs = (process.env["NIXAMP_MT_WARM"] ?? DEFAULT_PAIRS).split(",").map((one) => one.trim()).filter(Boolean);
|
|
28
|
+
const translator = new Translator({ keep: pairs.length });
|
|
29
|
+
console.error(`warming ${pairs.join(", ")}...`);
|
|
30
|
+
if (!(await translator.warm(pairs))) {
|
|
31
|
+
console.error(`could not load a translation pair: ${translator.lastFailure}`);
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
console.error(`ready: ${speech.model} and ${translator.loadedModels().join(", ")}`);
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
main().then((code) => {
|
|
39
|
+
process.exit(code);
|
|
40
|
+
}, (error: unknown) => {
|
|
41
|
+
console.error((error as Error).message);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./index-
|
|
1
|
+
import{t as e}from"./index-B0h4Nexr.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};
|