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,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcripts, kept: what a file, a link or a live said, written down once.
|
|
3
|
+
*
|
|
4
|
+
* Hearing costs a CPU for as long as the sound lasts, and a film heard on
|
|
5
|
+
* Tuesday says the same words on Thursday. So what the ear hears is kept
|
|
6
|
+
* here, on nixamp.com, under an identity of the media rather than of the
|
|
7
|
+
* channel that happened to be playing it: a file is its fingerprint, a
|
|
8
|
+
* link is its address, and a live is the one broadcast it was. The next
|
|
9
|
+
* captioner to meet the same media reads the lines instead of hearing
|
|
10
|
+
* them, and a translation is done once and kept beside the original.
|
|
11
|
+
*
|
|
12
|
+
* Lines are seconds into the media, not wall-clock moments. A captioner
|
|
13
|
+
* turns its wall-clock stamps into offsets from the channel's start on the
|
|
14
|
+
* way in, and back on the way out, so a transcript of a file means the
|
|
15
|
+
* same thing whichever server plays it.
|
|
16
|
+
*
|
|
17
|
+
* One row per (media, language). The original is the row translated from
|
|
18
|
+
* nothing; a translation says which language it came from. A row grows by
|
|
19
|
+
* appending: a live is captioned as it happens and a file may be heard in
|
|
20
|
+
* pieces, on different days, by different servers. A whole-media pass --
|
|
21
|
+
* `nixamp transcribe FILE` -- replaces the pieces with the whole and marks
|
|
22
|
+
* the row complete, after which nothing partial touches it again.
|
|
23
|
+
*/
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
import { closeSync, openSync, readSync, statSync } from "node:fs";
|
|
26
|
+
/** A row grows no further than this; a day of talk is under half of it. */
|
|
27
|
+
export const MAX_LINES = 20_000;
|
|
28
|
+
/** A line longer than this is not a line. */
|
|
29
|
+
export const MAX_LINE_CHARS = 2000;
|
|
30
|
+
/** How much of a file's bytes go into its fingerprint, at each end. */
|
|
31
|
+
export const FINGERPRINT_BYTES = 1024 * 1024;
|
|
32
|
+
// --- identity ----------------------------------------------------------------
|
|
33
|
+
/**
|
|
34
|
+
* A file's identity from its size and a megabyte at each end. Hashing every
|
|
35
|
+
* byte of a film takes seconds a captioner does not have at start, and the
|
|
36
|
+
* ends plus the size tell two files apart as surely as anything short of
|
|
37
|
+
* the whole does. The v1 says how it was made, so a better way later does
|
|
38
|
+
* not collide with this one.
|
|
39
|
+
*/
|
|
40
|
+
export function fileFingerprint(path) {
|
|
41
|
+
const size = statSync(path).size;
|
|
42
|
+
const hash = createHash("sha256");
|
|
43
|
+
const sizeBytes = Buffer.alloc(8);
|
|
44
|
+
sizeBytes.writeBigUInt64LE(BigInt(size));
|
|
45
|
+
hash.update(sizeBytes);
|
|
46
|
+
const fd = openSync(path, "r");
|
|
47
|
+
try {
|
|
48
|
+
const head = Buffer.alloc(Math.min(FINGERPRINT_BYTES, size));
|
|
49
|
+
readSync(fd, head, 0, head.length, 0);
|
|
50
|
+
hash.update(head);
|
|
51
|
+
if (size > FINGERPRINT_BYTES) {
|
|
52
|
+
const tail = Buffer.alloc(Math.min(FINGERPRINT_BYTES, size - FINGERPRINT_BYTES));
|
|
53
|
+
readSync(fd, tail, 0, tail.length, size - tail.length);
|
|
54
|
+
hash.update(tail);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
closeSync(fd);
|
|
59
|
+
}
|
|
60
|
+
return `file:v1:${hash.digest("hex")}`;
|
|
61
|
+
}
|
|
62
|
+
/** A link's identity: the address without its fragment, which no server sees. */
|
|
63
|
+
export function mediaOfUrl(url) {
|
|
64
|
+
const trimmed = url.trim();
|
|
65
|
+
try {
|
|
66
|
+
const parsed = new URL(trimmed);
|
|
67
|
+
parsed.hash = "";
|
|
68
|
+
return `url:${parsed.toString()}`;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return `url:${trimmed}`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** A broadcast's identity: the server, the channel, and when it began, so a second airing is a second transcript. */
|
|
75
|
+
export function mediaOfLive(server, channel, startedAt) {
|
|
76
|
+
let host = server.trim().replace(/\/+$/, "");
|
|
77
|
+
try {
|
|
78
|
+
// A bare host:port parses as a scheme and a path; give it one first.
|
|
79
|
+
host = new URL(host.includes("://") ? host : `https://${host}`).host;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
host = host.replace(/^https?:\/\//, "");
|
|
83
|
+
}
|
|
84
|
+
return `live:${host}/${channel}@${Math.floor(startedAt)}`;
|
|
85
|
+
}
|
|
86
|
+
export function kindOf(media) {
|
|
87
|
+
if (media.startsWith("file:"))
|
|
88
|
+
return "file";
|
|
89
|
+
if (media.startsWith("live:"))
|
|
90
|
+
return "live";
|
|
91
|
+
return "url";
|
|
92
|
+
}
|
|
93
|
+
/** The id a transcript is addressed by: a hash, so a link with a key in it is not in the address bar. */
|
|
94
|
+
export function transcriptIdOf(media) {
|
|
95
|
+
return createHash("sha256").update(media).digest("hex");
|
|
96
|
+
}
|
|
97
|
+
/** A transcript id as a request names one, or null when it is not one. */
|
|
98
|
+
export function transcriptId(value) {
|
|
99
|
+
return typeof value === "string" && /^[0-9a-f]{64}$/.test(value) ? value : null;
|
|
100
|
+
}
|
|
101
|
+
/** Either the id itself or a media identity, as a route accepts both. */
|
|
102
|
+
export function idFrom(value) {
|
|
103
|
+
return transcriptId(value) ?? transcriptIdOf(value);
|
|
104
|
+
}
|
|
105
|
+
// --- lines -------------------------------------------------------------------
|
|
106
|
+
/** Lines as a request or a row hands them in: only the well-formed ones, tidied. */
|
|
107
|
+
export function linesFrom(value) {
|
|
108
|
+
let parsed = value;
|
|
109
|
+
if (typeof value === "string") {
|
|
110
|
+
try {
|
|
111
|
+
parsed = JSON.parse(value);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (!Array.isArray(parsed))
|
|
118
|
+
return [];
|
|
119
|
+
const lines = [];
|
|
120
|
+
for (const one of parsed) {
|
|
121
|
+
if (!one || typeof one !== "object")
|
|
122
|
+
continue;
|
|
123
|
+
const { start, end, text } = one;
|
|
124
|
+
if (typeof text !== "string" || typeof start !== "number" || !Number.isFinite(start) || start < 0)
|
|
125
|
+
continue;
|
|
126
|
+
const words = text.replace(/\s+/g, " ").trim().slice(0, MAX_LINE_CHARS);
|
|
127
|
+
if (words === "")
|
|
128
|
+
continue;
|
|
129
|
+
const until = typeof end === "number" && Number.isFinite(end) && end > start ? end : start;
|
|
130
|
+
lines.push({ start: round(start), end: round(until), text: words });
|
|
131
|
+
}
|
|
132
|
+
return lines;
|
|
133
|
+
}
|
|
134
|
+
function round(seconds) {
|
|
135
|
+
return Math.round(seconds * 1000) / 1000;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Lines in order, with the duplicates gone. Two servers playing one file
|
|
139
|
+
* both append what they heard, a captioner that restarted hears a window
|
|
140
|
+
* again: the second copy of a moment says the same thing, later, and a
|
|
141
|
+
* viewer would read it twice. A line whose span mostly overlaps one already
|
|
142
|
+
* kept is that second copy.
|
|
143
|
+
*/
|
|
144
|
+
export function mergeLines(lines) {
|
|
145
|
+
const sorted = [...lines].sort((a, b) => a.start - b.start || a.end - b.end);
|
|
146
|
+
const kept = [];
|
|
147
|
+
for (const line of sorted) {
|
|
148
|
+
const last = kept[kept.length - 1];
|
|
149
|
+
if (last && overlaps(last, line)) {
|
|
150
|
+
// The same moment twice: keep the longer account of it.
|
|
151
|
+
if (line.text.length > last.text.length && Math.abs(line.start - last.start) < 1)
|
|
152
|
+
kept[kept.length - 1] = line;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
kept.push(line);
|
|
156
|
+
}
|
|
157
|
+
return kept;
|
|
158
|
+
}
|
|
159
|
+
function overlaps(a, b) {
|
|
160
|
+
const shorter = Math.min(a.end - a.start, b.end - b.start);
|
|
161
|
+
const shared = Math.min(a.end, b.end) - Math.max(a.start, b.start);
|
|
162
|
+
if (shorter <= 0)
|
|
163
|
+
return Math.abs(a.start - b.start) < 0.5;
|
|
164
|
+
return shared > shorter * 0.6;
|
|
165
|
+
}
|
|
166
|
+
/** Whether the kept lines already say what this span says: over half of it is covered. */
|
|
167
|
+
export function covered(lines, start, end) {
|
|
168
|
+
const span = end - start;
|
|
169
|
+
if (span <= 0)
|
|
170
|
+
return [];
|
|
171
|
+
const inside = lines.filter((line) => Math.min(line.end, end) - Math.max(line.start, start) > 0);
|
|
172
|
+
const shared = inside.reduce((sum, line) => sum + (Math.min(line.end, end) - Math.max(line.start, start)), 0);
|
|
173
|
+
return shared > span / 2 ? inside : [];
|
|
174
|
+
}
|
|
175
|
+
/** The line for a moment, if there is one: the one that begins nearest, within a second. */
|
|
176
|
+
export function lineAt(lines, start) {
|
|
177
|
+
let nearest = null;
|
|
178
|
+
for (const line of lines) {
|
|
179
|
+
const off = Math.abs(line.start - start);
|
|
180
|
+
if (off <= 1 && (nearest === null || off < Math.abs(nearest.start - start)))
|
|
181
|
+
nearest = line;
|
|
182
|
+
}
|
|
183
|
+
return nearest;
|
|
184
|
+
}
|
|
185
|
+
/** How far the lines reach, seconds. */
|
|
186
|
+
export function reach(lines) {
|
|
187
|
+
return lines.reduce((most, line) => Math.max(most, line.end), 0);
|
|
188
|
+
}
|
|
189
|
+
// --- formats -----------------------------------------------------------------
|
|
190
|
+
/** Seconds as a subtitle clock: 01:02:03,456 for SRT, 01:02:03.456 for VTT. */
|
|
191
|
+
export function stamp(seconds, separator = ",") {
|
|
192
|
+
const whole = Math.max(0, Math.floor(seconds));
|
|
193
|
+
const ms = Math.max(0, Math.round((seconds - whole) * 1000));
|
|
194
|
+
const two = (n) => String(n).padStart(2, "0");
|
|
195
|
+
return `${two(Math.floor(whole / 3600))}:${two(Math.floor((whole % 3600) / 60))}:${two(whole % 60)}${separator}${String(ms).padStart(3, "0")}`;
|
|
196
|
+
}
|
|
197
|
+
export function toSrt(lines) {
|
|
198
|
+
return lines
|
|
199
|
+
.map((line, i) => `${i + 1}\n${stamp(line.start)} --> ${stamp(Math.max(line.end, line.start + 0.5))}\n${line.text}\n`)
|
|
200
|
+
.join("\n");
|
|
201
|
+
}
|
|
202
|
+
export function toVtt(lines) {
|
|
203
|
+
const cues = lines.map((line) => `${stamp(line.start, ".")} --> ${stamp(Math.max(line.end, line.start + 0.5), ".")}\n${line.text}\n`);
|
|
204
|
+
return `WEBVTT\n\n${cues.join("\n")}`;
|
|
205
|
+
}
|
|
206
|
+
/** The words alone, one line each, as a person reads them. */
|
|
207
|
+
export function toText(lines) {
|
|
208
|
+
return lines.map((line) => line.text).join("\n");
|
|
209
|
+
}
|
|
210
|
+
export function formatOf(value) {
|
|
211
|
+
if (value === null || value === undefined || value === "")
|
|
212
|
+
return "json";
|
|
213
|
+
return value === "json" || value === "srt" || value === "vtt" || value === "txt" ? value : null;
|
|
214
|
+
}
|
|
215
|
+
/** A two-letter language, or "" for none; null when the value is something else. */
|
|
216
|
+
export function languageCode(value) {
|
|
217
|
+
if (value === null || value === undefined)
|
|
218
|
+
return "";
|
|
219
|
+
if (typeof value !== "string")
|
|
220
|
+
return null;
|
|
221
|
+
const code = value.trim().toLowerCase();
|
|
222
|
+
if (code === "" || code === "original")
|
|
223
|
+
return "";
|
|
224
|
+
return /^[a-z]{2}$/.test(code) ? code : null;
|
|
225
|
+
}
|
|
226
|
+
// --- the store ---------------------------------------------------------------
|
|
227
|
+
const SCHEMA = `
|
|
228
|
+
CREATE TABLE IF NOT EXISTS transcripts (
|
|
229
|
+
id TEXT NOT NULL,
|
|
230
|
+
language TEXT NOT NULL DEFAULT '',
|
|
231
|
+
media TEXT NOT NULL,
|
|
232
|
+
kind TEXT NOT NULL DEFAULT 'url',
|
|
233
|
+
translated_from TEXT,
|
|
234
|
+
model TEXT NOT NULL DEFAULT '',
|
|
235
|
+
complete BOOLEAN NOT NULL DEFAULT false,
|
|
236
|
+
title TEXT NOT NULL DEFAULT '',
|
|
237
|
+
by_account TEXT NOT NULL DEFAULT '',
|
|
238
|
+
seconds DOUBLE PRECISION NOT NULL DEFAULT 0,
|
|
239
|
+
lines JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
240
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
241
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
242
|
+
PRIMARY KEY (id, language)
|
|
243
|
+
);
|
|
244
|
+
CREATE INDEX IF NOT EXISTS transcripts_by_account ON transcripts (by_account, updated_at DESC);
|
|
245
|
+
`;
|
|
246
|
+
export class Transcripts {
|
|
247
|
+
db;
|
|
248
|
+
onEvent;
|
|
249
|
+
ready = null;
|
|
250
|
+
constructor(db, onEvent = () => { }) {
|
|
251
|
+
this.db = db;
|
|
252
|
+
this.onEvent = onEvent;
|
|
253
|
+
}
|
|
254
|
+
async ensure() {
|
|
255
|
+
this.ready ??= this.db.query(SCHEMA).then(() => undefined);
|
|
256
|
+
await this.ready;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Keep lines. New media is a new row; known media grows by these lines,
|
|
260
|
+
* unless the row is already whole -- then pieces are nothing it needs --
|
|
261
|
+
* or these lines are the whole, which replaces whatever pieces there
|
|
262
|
+
* were. Answers the row as it now stands.
|
|
263
|
+
*/
|
|
264
|
+
async save(ask) {
|
|
265
|
+
const lines = mergeLines(linesFrom(ask.lines)).slice(0, MAX_LINES);
|
|
266
|
+
const id = transcriptIdOf(ask.media);
|
|
267
|
+
const complete = ask.complete === true;
|
|
268
|
+
await this.ensure();
|
|
269
|
+
const { rows } = await this.db.query(`INSERT INTO transcripts (id, language, media, kind, translated_from, model, complete, title, by_account, seconds, lines)
|
|
270
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb)
|
|
271
|
+
ON CONFLICT (id, language) DO UPDATE SET
|
|
272
|
+
lines = CASE
|
|
273
|
+
WHEN EXCLUDED.complete THEN EXCLUDED.lines
|
|
274
|
+
WHEN transcripts.complete THEN transcripts.lines
|
|
275
|
+
WHEN jsonb_array_length(transcripts.lines) >= $12 THEN transcripts.lines
|
|
276
|
+
ELSE transcripts.lines || EXCLUDED.lines
|
|
277
|
+
END,
|
|
278
|
+
seconds = CASE WHEN EXCLUDED.complete THEN EXCLUDED.seconds ELSE GREATEST(transcripts.seconds, EXCLUDED.seconds) END,
|
|
279
|
+
complete = transcripts.complete OR EXCLUDED.complete,
|
|
280
|
+
model = CASE WHEN EXCLUDED.model = '' THEN transcripts.model ELSE EXCLUDED.model END,
|
|
281
|
+
title = CASE WHEN EXCLUDED.title = '' THEN transcripts.title ELSE EXCLUDED.title END,
|
|
282
|
+
translated_from = COALESCE(EXCLUDED.translated_from, transcripts.translated_from),
|
|
283
|
+
updated_at = now()
|
|
284
|
+
RETURNING *`, [
|
|
285
|
+
id,
|
|
286
|
+
ask.language,
|
|
287
|
+
ask.media,
|
|
288
|
+
kindOf(ask.media),
|
|
289
|
+
ask.translatedFrom ?? null,
|
|
290
|
+
ask.model ?? "",
|
|
291
|
+
complete,
|
|
292
|
+
(ask.title ?? "").slice(0, 200),
|
|
293
|
+
ask.by,
|
|
294
|
+
reach(lines),
|
|
295
|
+
JSON.stringify(lines),
|
|
296
|
+
MAX_LINES,
|
|
297
|
+
]);
|
|
298
|
+
const row = rows[0];
|
|
299
|
+
return row ? rowToTranscript(row) : null;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* The transcript of some media in a language: the original when none is
|
|
303
|
+
* named, which is the heard row -- the whole one when there is one.
|
|
304
|
+
*/
|
|
305
|
+
async get(id, language = "") {
|
|
306
|
+
await this.ensure();
|
|
307
|
+
const { rows } = language === ""
|
|
308
|
+
? await this.db.query(`SELECT * FROM transcripts WHERE id = $1 AND translated_from IS NULL
|
|
309
|
+
ORDER BY complete DESC, updated_at DESC LIMIT 1`, [id])
|
|
310
|
+
: await this.db.query("SELECT * FROM transcripts WHERE id = $1 AND language = $2 LIMIT 1", [id, language]);
|
|
311
|
+
const row = rows[0];
|
|
312
|
+
return row ? rowToTranscript(row) : null;
|
|
313
|
+
}
|
|
314
|
+
/** Every language some media has been written down in, with how much of it. */
|
|
315
|
+
async languages(id) {
|
|
316
|
+
await this.ensure();
|
|
317
|
+
const { rows } = await this.db.query(`SELECT language, translated_from, complete, jsonb_array_length(lines) AS lines
|
|
318
|
+
FROM transcripts WHERE id = $1 ORDER BY translated_from NULLS FIRST, language`, [id]);
|
|
319
|
+
return rows.map((row) => ({
|
|
320
|
+
language: String(row["language"] ?? ""),
|
|
321
|
+
translatedFrom: row["translated_from"] === null || row["translated_from"] === undefined ? null : String(row["translated_from"]),
|
|
322
|
+
lines: Number(row["lines"] ?? 0),
|
|
323
|
+
complete: row["complete"] === true,
|
|
324
|
+
}));
|
|
325
|
+
}
|
|
326
|
+
/** What an account has had written down, newest first. */
|
|
327
|
+
async list(by, limit = 50) {
|
|
328
|
+
await this.ensure();
|
|
329
|
+
const { rows } = await this.db.query(`SELECT id, language, media, kind, translated_from, model, complete, title, by_account, seconds, updated_at,
|
|
330
|
+
jsonb_array_length(lines) AS lines
|
|
331
|
+
FROM transcripts WHERE by_account = $1 ORDER BY updated_at DESC LIMIT $2`, [by, Math.max(1, Math.min(500, limit))]);
|
|
332
|
+
return rows.map((row) => ({ ...rowToTranscript({ ...row, lines: "[]" }), lines: Number(row["lines"] ?? 0) }));
|
|
333
|
+
}
|
|
334
|
+
/** Forget some media, in every language. Only whoever stored it may. Says whether anything went. */
|
|
335
|
+
async forget(id, by) {
|
|
336
|
+
await this.ensure();
|
|
337
|
+
const { rows } = await this.db.query("DELETE FROM transcripts WHERE id = $1 AND by_account = $2 RETURNING language", [id, by]);
|
|
338
|
+
return rows.length > 0;
|
|
339
|
+
}
|
|
340
|
+
/** Never throws: a store that is having a moment costs the memory, not the request. */
|
|
341
|
+
async quietly(what, run) {
|
|
342
|
+
try {
|
|
343
|
+
return await run();
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
this.onEvent(` ${what} did not persist: ${error.message}`);
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function rowToTranscript(row) {
|
|
352
|
+
const updated = row["updated_at"];
|
|
353
|
+
return {
|
|
354
|
+
id: String(row["id"] ?? ""),
|
|
355
|
+
media: String(row["media"] ?? ""),
|
|
356
|
+
kind: kindOf(String(row["media"] ?? "")),
|
|
357
|
+
language: String(row["language"] ?? ""),
|
|
358
|
+
translatedFrom: row["translated_from"] === null || row["translated_from"] === undefined ? null : String(row["translated_from"]),
|
|
359
|
+
model: String(row["model"] ?? ""),
|
|
360
|
+
complete: row["complete"] === true,
|
|
361
|
+
title: String(row["title"] ?? ""),
|
|
362
|
+
by: String(row["by_account"] ?? ""),
|
|
363
|
+
seconds: Number(row["seconds"] ?? 0),
|
|
364
|
+
lines: mergeLines(linesFrom(row["lines"])),
|
|
365
|
+
updatedAt: updated instanceof Date ? updated.toISOString() : typeof updated === "string" ? updated : "",
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
/** A transcript as the routes answer it, with or without its lines. */
|
|
369
|
+
export function wire(transcript, languages = []) {
|
|
370
|
+
return {
|
|
371
|
+
id: transcript.id,
|
|
372
|
+
media: transcript.media,
|
|
373
|
+
kind: transcript.kind,
|
|
374
|
+
language: transcript.language,
|
|
375
|
+
translatedFrom: transcript.translatedFrom,
|
|
376
|
+
model: transcript.model,
|
|
377
|
+
complete: transcript.complete,
|
|
378
|
+
title: transcript.title,
|
|
379
|
+
seconds: transcript.seconds,
|
|
380
|
+
updatedAt: transcript.updatedAt,
|
|
381
|
+
lines: transcript.lines,
|
|
382
|
+
languages,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
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 { type Session } from "./session.ts";
|
|
10
|
+
export interface TranslateDeps {
|
|
11
|
+
fetcher?: typeof fetch;
|
|
12
|
+
session?: Pick<Session, "site" | "token"> | null;
|
|
13
|
+
/** What stdin says, when no text is on the command line; the tests hand it in. */
|
|
14
|
+
stdin?: () => Promise<string>;
|
|
15
|
+
}
|
|
16
|
+
export declare function translate(argv: string[], deps?: TranslateDeps): Promise<number>;
|
|
@@ -0,0 +1,97 @@
|
|
|
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 } from "./session.js";
|
|
10
|
+
import { translateTexts } from "./transcript-client.js";
|
|
11
|
+
import { languageCode } from "./transcripts.js";
|
|
12
|
+
const HELP = `nixamp translate — say it in another language.
|
|
13
|
+
|
|
14
|
+
nixamp translate --to sv "Hello there" Swedish, from English
|
|
15
|
+
nixamp translate --from de --to en "Guten Tag"
|
|
16
|
+
cat lines.txt | nixamp translate --to de each line, in order
|
|
17
|
+
nixamp translate --languages what nixamp.com can translate between
|
|
18
|
+
|
|
19
|
+
The models are open-source (OPUS-MT) and run on nixamp.com's own CPU; a pair
|
|
20
|
+
with no model of its own goes through English. Needs a sign-in
|
|
21
|
+
(\`nixamp login\`).
|
|
22
|
+
`;
|
|
23
|
+
function flag(argv, name) {
|
|
24
|
+
const at = argv.indexOf(name);
|
|
25
|
+
return at === -1 ? undefined : argv[at + 1];
|
|
26
|
+
}
|
|
27
|
+
async function readStdin() {
|
|
28
|
+
const chunks = [];
|
|
29
|
+
for await (const chunk of process.stdin)
|
|
30
|
+
chunks.push(chunk);
|
|
31
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
32
|
+
}
|
|
33
|
+
export async function translate(argv, deps = {}) {
|
|
34
|
+
if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
|
|
35
|
+
console.log(HELP);
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
const session = deps.session === undefined ? readSession() : deps.session;
|
|
39
|
+
if (session === null) {
|
|
40
|
+
console.error("nixamp: not signed in. Try `nixamp login`.");
|
|
41
|
+
return 1;
|
|
42
|
+
}
|
|
43
|
+
const fetcher = deps.fetcher ?? fetch;
|
|
44
|
+
const signed = { site: session.site, token: session.token };
|
|
45
|
+
if (argv.includes("--languages")) {
|
|
46
|
+
let response;
|
|
47
|
+
try {
|
|
48
|
+
response = await fetcher(`${signed.site.replace(/\/+$/, "")}/api/v1/translate`);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
console.error(`nixamp: could not reach ${signed.site}: ${error.message}`);
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
const body = (await response.json().catch(() => ({})));
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
console.error(`nixamp: ${body.error ?? `nixamp.com answered ${response.status}`}`);
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
if (argv.includes("--json")) {
|
|
60
|
+
console.log(JSON.stringify(body, null, 2));
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
if (body.available === false)
|
|
64
|
+
console.error("nixamp: that nixamp has no translation models; nixamp.com does.");
|
|
65
|
+
for (const language of body.languages ?? []) {
|
|
66
|
+
console.log(`${language.code} ${language.name.padEnd(11)} ${language.native.padEnd(17)} -> ${language.targets.join(" ")}`);
|
|
67
|
+
}
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
const to = languageCode(flag(argv, "--to"));
|
|
71
|
+
const from = languageCode(flag(argv, "--from") ?? "en");
|
|
72
|
+
if (!to || from === null || from === "") {
|
|
73
|
+
console.error("nixamp: --to (and --from, English by default) are two-letter language codes, such as sv.");
|
|
74
|
+
return 64;
|
|
75
|
+
}
|
|
76
|
+
const withValue = new Set(["--to", "--from"]);
|
|
77
|
+
const given = argv.filter((one, at) => !one.startsWith("-") && !(at > 0 && withValue.has(argv[at - 1])));
|
|
78
|
+
const text = given.length > 0 ? given.join(" ") : await (deps.stdin ?? readStdin)();
|
|
79
|
+
const lines = text.split(/\r?\n/).map((line) => line.trimEnd());
|
|
80
|
+
while (lines.length > 0 && lines[lines.length - 1] === "")
|
|
81
|
+
lines.pop();
|
|
82
|
+
if (lines.length === 0) {
|
|
83
|
+
console.error("nixamp: translate what? Pass the text, or pipe it in.");
|
|
84
|
+
return 64;
|
|
85
|
+
}
|
|
86
|
+
const got = await translateTexts(signed, lines, from, to, fetcher);
|
|
87
|
+
if (!got.ok) {
|
|
88
|
+
console.error(`nixamp: ${got.error}`);
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
if (argv.includes("--json")) {
|
|
92
|
+
console.log(JSON.stringify(got.body, null, 2));
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
console.log(got.body.texts.join("\n"));
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type Translator } from "./translate.ts";
|
|
2
|
+
import { type Transcript, type TranscriptLine, type Transcripts } from "./transcripts.ts";
|
|
3
|
+
export interface Progress {
|
|
4
|
+
done: number;
|
|
5
|
+
total: number;
|
|
6
|
+
}
|
|
7
|
+
/** What a request should answer: the status and the body's extra fields. */
|
|
8
|
+
export type Answer = {
|
|
9
|
+
status: 200;
|
|
10
|
+
transcript: Transcript;
|
|
11
|
+
} | {
|
|
12
|
+
status: 202;
|
|
13
|
+
transcript: Transcript | null;
|
|
14
|
+
translating: Progress;
|
|
15
|
+
} | {
|
|
16
|
+
status: 404 | 409 | 503;
|
|
17
|
+
error: string;
|
|
18
|
+
};
|
|
19
|
+
export declare class StoredTranslations {
|
|
20
|
+
private readonly store;
|
|
21
|
+
private readonly translator;
|
|
22
|
+
private readonly onEvent;
|
|
23
|
+
private readonly jobs;
|
|
24
|
+
constructor(store: Transcripts, translator: Translator | undefined, onEvent?: (message: string) => void);
|
|
25
|
+
/** The lines of the original that the translation does not yet have. */
|
|
26
|
+
static missing(original: Transcript, translation: Transcript | null): TranscriptLine[];
|
|
27
|
+
/**
|
|
28
|
+
* The transcript in a language, or the job making it. The original when
|
|
29
|
+
* the language is the one it was heard in.
|
|
30
|
+
*/
|
|
31
|
+
get(id: string, language: string, by: string): Promise<Answer>;
|
|
32
|
+
private start;
|
|
33
|
+
private run;
|
|
34
|
+
/** What is being translated right now. */
|
|
35
|
+
running(): {
|
|
36
|
+
key: string;
|
|
37
|
+
progress: Progress;
|
|
38
|
+
}[];
|
|
39
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
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.js";
|
|
17
|
+
import { BATCH } from "./translate.js";
|
|
18
|
+
import { lineAt } from "./transcripts.js";
|
|
19
|
+
export class StoredTranslations {
|
|
20
|
+
store;
|
|
21
|
+
translator;
|
|
22
|
+
onEvent;
|
|
23
|
+
jobs = new Map();
|
|
24
|
+
constructor(store, translator, onEvent = () => { }) {
|
|
25
|
+
this.store = store;
|
|
26
|
+
this.translator = translator;
|
|
27
|
+
this.onEvent = onEvent;
|
|
28
|
+
}
|
|
29
|
+
/** The lines of the original that the translation does not yet have. */
|
|
30
|
+
static missing(original, translation) {
|
|
31
|
+
if (!translation)
|
|
32
|
+
return original.lines;
|
|
33
|
+
return original.lines.filter((line) => lineAt(translation.lines, line.start) === null);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The transcript in a language, or the job making it. The original when
|
|
37
|
+
* the language is the one it was heard in.
|
|
38
|
+
*/
|
|
39
|
+
async get(id, language, by) {
|
|
40
|
+
const original = await this.store.get(id);
|
|
41
|
+
if (!original)
|
|
42
|
+
return { status: 404, error: "nothing has been written down for that" };
|
|
43
|
+
if (language === "" || language === original.language)
|
|
44
|
+
return { status: 200, transcript: original };
|
|
45
|
+
const translation = await this.store.get(id, language);
|
|
46
|
+
const missing = StoredTranslations.missing(original, translation);
|
|
47
|
+
if (translation && missing.length === 0)
|
|
48
|
+
return { status: 200, transcript: translation };
|
|
49
|
+
if (original.language === "")
|
|
50
|
+
return { status: 409, error: "the language this was heard in is not known, so it cannot be translated" };
|
|
51
|
+
if (!this.translator)
|
|
52
|
+
return { status: 503, error: "this nixamp cannot translate: no model here. nixamp.com can." };
|
|
53
|
+
if (!this.translator.can(original.language, language)) {
|
|
54
|
+
return { status: 409, error: `there is no model here from ${original.language} to ${language}` };
|
|
55
|
+
}
|
|
56
|
+
const key = `${id}|${language}`;
|
|
57
|
+
let job = this.jobs.get(key);
|
|
58
|
+
if (job?.error) {
|
|
59
|
+
this.jobs.delete(key);
|
|
60
|
+
return { status: 503, error: job.error };
|
|
61
|
+
}
|
|
62
|
+
if (!job) {
|
|
63
|
+
job = this.start(key, original, translation, missing, language, by);
|
|
64
|
+
// A short one is done before the ask is answered.
|
|
65
|
+
if (missing.length <= BATCH) {
|
|
66
|
+
await job.finished;
|
|
67
|
+
if (job.error) {
|
|
68
|
+
this.jobs.delete(key);
|
|
69
|
+
return { status: 503, error: job.error };
|
|
70
|
+
}
|
|
71
|
+
const made = await this.store.get(id, language);
|
|
72
|
+
if (made)
|
|
73
|
+
return { status: 200, transcript: made };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { status: 202, transcript: translation, translating: { ...job.progress } };
|
|
77
|
+
}
|
|
78
|
+
start(key, original, translation, missing, language, by) {
|
|
79
|
+
const job = { progress: { done: original.lines.length - missing.length, total: original.lines.length }, finished: Promise.resolve(), error: "" };
|
|
80
|
+
job.finished = this.run(original, translation, missing, language, by, job)
|
|
81
|
+
.catch((error) => {
|
|
82
|
+
job.error = error instanceof SpeechError ? error.message : `translating failed: ${error.message}`;
|
|
83
|
+
this.onEvent(` translating ${original.title || original.media} to ${language}: ${job.error}`);
|
|
84
|
+
})
|
|
85
|
+
.finally(() => {
|
|
86
|
+
if (!job.error)
|
|
87
|
+
this.jobs.delete(key);
|
|
88
|
+
});
|
|
89
|
+
this.jobs.set(key, job);
|
|
90
|
+
return job;
|
|
91
|
+
}
|
|
92
|
+
async run(original, translation, missing, language, by, job) {
|
|
93
|
+
const translator = this.translator;
|
|
94
|
+
const made = translation ? [...translation.lines] : [];
|
|
95
|
+
let model = "";
|
|
96
|
+
for (let at = 0; at < missing.length; at += BATCH) {
|
|
97
|
+
const batch = missing.slice(at, at + BATCH);
|
|
98
|
+
const done = await translator.translate(batch.map((line) => line.text), original.language, language);
|
|
99
|
+
model = done.model;
|
|
100
|
+
const lines = batch.map((line, i) => ({ start: line.start, end: line.end, text: done.texts[i] ?? "" })).filter((line) => line.text !== "");
|
|
101
|
+
made.push(...lines);
|
|
102
|
+
await this.store.save({
|
|
103
|
+
media: original.media, language, translatedFrom: original.language, model, title: original.title, by, lines,
|
|
104
|
+
});
|
|
105
|
+
job.progress.done += batch.length;
|
|
106
|
+
}
|
|
107
|
+
if (original.complete) {
|
|
108
|
+
// Whole, like the original: the pieces are replaced by the lot, and nothing partial touches it again.
|
|
109
|
+
await this.store.save({
|
|
110
|
+
media: original.media, language, translatedFrom: original.language, model, title: original.title, by, lines: made, complete: true,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (missing.length > 0)
|
|
114
|
+
this.onEvent(` translated ${missing.length} lines of ${original.title || original.media} to ${language}`);
|
|
115
|
+
}
|
|
116
|
+
/** What is being translated right now. */
|
|
117
|
+
running() {
|
|
118
|
+
return [...this.jobs.entries()].filter(([, job]) => !job.error).map(([key, job]) => ({ key, progress: { ...job.progress } }));
|
|
119
|
+
}
|
|
120
|
+
}
|