opencaptions 1.2.1 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -7
- package/dist/captioner.js +31 -0
- package/dist/captions.js +30 -18
- package/dist/cli.js +10 -9
- package/dist/engine.js +75 -11
- package/dist/generated/render_formats.json +42 -0
- package/dist/local/cache.js +8 -0
- package/dist/local/fonts.js +81 -0
- package/dist/local/index.js +213 -0
- package/dist/local/media.js +90 -0
- package/dist/local/transcribe.js +260 -0
- package/dist/mcp.js +23 -24
- package/engine/fonts/BarlowCondensed-MediumItalic.ttf +0 -0
- package/engine/fonts/BarlowCondensed-OFL.txt +93 -0
- package/engine/fonts/InstrumentSerif-Italic.ttf +0 -0
- package/engine/fonts/InstrumentSerif-OFL.txt +93 -0
- package/engine/fonts/InstrumentSerif-Regular.ttf +0 -0
- package/engine/fonts/Inter-OFL.txt +93 -0
- package/engine/fonts/Inter.ttf +0 -0
- package/engine/fonts/Montserrat-Black.ttf +0 -0
- package/engine/fonts/Montserrat-OFL.txt +93 -0
- package/engine/fonts/NimbusRoman-Bold.ttf +0 -0
- package/engine/fonts/NimbusRoman-LICENSE.txt +14 -0
- package/engine/fonts/NotoSansArabic-OFL.txt +93 -0
- package/engine/fonts/NotoSansArabic.ttf +0 -0
- package/engine/fonts/NotoSansDevanagari-OFL.txt +93 -0
- package/engine/fonts/NotoSansDevanagari.ttf +0 -0
- package/engine/fonts/NotoSansHebrew-OFL.txt +93 -0
- package/engine/fonts/NotoSansHebrew.ttf +0 -0
- package/engine/fonts/NotoSansThai-OFL.txt +93 -0
- package/engine/fonts/NotoSansThai.ttf +0 -0
- package/engine/fonts/Poppins-ExtraBold.ttf +0 -0
- package/engine/fonts/Poppins-OFL.txt +93 -0
- package/engine/opencaptions_engine.wasm +0 -0
- package/package.json +5 -3
- package/server.json +16 -9
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Captioning on this computer, with no server: the speech model through sherpa-onnx, the engine's
|
|
3
|
+
* WebAssembly to draw, FFmpeg to read and write video. A project is a file beside the video
|
|
4
|
+
* (`<name>.opencaptions.json`: the transcript, the style and the offset), and its path is the
|
|
5
|
+
* project id the tools pass around. Everything is done before a call returns, so there are no
|
|
6
|
+
* tickets.
|
|
7
|
+
*/
|
|
8
|
+
import { readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
9
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
10
|
+
import { applyFixes, dealt, defaultOutput, styled, viewTranscript, } from "../captions.js";
|
|
11
|
+
import { OpenCaptionsError } from "../client.js";
|
|
12
|
+
import { Scene, subtitles } from "../engine.js";
|
|
13
|
+
import formats from "../generated/render_formats.json" with { type: "json" };
|
|
14
|
+
import { LOOKS } from "../looks.js";
|
|
15
|
+
import { ensureFont } from "./fonts.js";
|
|
16
|
+
import { encode, probe } from "./media.js";
|
|
17
|
+
import { transcribe } from "./transcribe.js";
|
|
18
|
+
const SIDECAR = ".opencaptions.json";
|
|
19
|
+
async function read(id) {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(await readFile(id, "utf8"));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new OpenCaptionsError(`There is no OpenCaptions project at ${id}.`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function write(id, project) {
|
|
28
|
+
await writeFile(id, `${JSON.stringify(project, null, 2)}\n`);
|
|
29
|
+
}
|
|
30
|
+
async function open(source) {
|
|
31
|
+
if (/^https?:\/\//i.test(source)) {
|
|
32
|
+
throw new OpenCaptionsError("Without a server, the video has to be a file on this computer: download it first.");
|
|
33
|
+
}
|
|
34
|
+
const path = resolve(source);
|
|
35
|
+
if (!(await stat(path).catch(() => undefined))?.isFile()) {
|
|
36
|
+
throw new OpenCaptionsError(`There is no video at ${path}.`);
|
|
37
|
+
}
|
|
38
|
+
const id = join(dirname(path), `${basename(path, extname(path))}${SIDECAR}`);
|
|
39
|
+
const existing = await read(id).catch(() => undefined);
|
|
40
|
+
await write(id, {
|
|
41
|
+
version: 1,
|
|
42
|
+
source: path,
|
|
43
|
+
transcript: null,
|
|
44
|
+
style_config: existing?.style_config ?? null,
|
|
45
|
+
caption_offset_ms: 0,
|
|
46
|
+
});
|
|
47
|
+
return id;
|
|
48
|
+
}
|
|
49
|
+
// The output's size and frame rate from the source's, as the API decides them
|
|
50
|
+
// (`resolve_render_inputs` in apps/api/app/services/render_formats.py).
|
|
51
|
+
const SHORT_SIDES = { "2160": 2160, "1080": 1080, "720": 720 };
|
|
52
|
+
const FRAME_RATES = { "30": 30, "60": 60 };
|
|
53
|
+
/** Python's `round`: halves go to the even neighbour. */
|
|
54
|
+
const round = (value) => {
|
|
55
|
+
const floor = Math.floor(value);
|
|
56
|
+
const diff = value - floor;
|
|
57
|
+
return diff > 0.5 || (diff === 0.5 && floor % 2 !== 0) ? floor + 1 : floor;
|
|
58
|
+
};
|
|
59
|
+
// Encoders need even dimensions in 4:2:0.
|
|
60
|
+
const even = (value) => Math.max(2, round(value) & ~1);
|
|
61
|
+
export function geometry(source, save) {
|
|
62
|
+
let { width, height } = source;
|
|
63
|
+
const target = SHORT_SIDES[save.resolution ?? "original"];
|
|
64
|
+
const short = Math.min(width, height);
|
|
65
|
+
if (target && target !== short) {
|
|
66
|
+
[width, height] = [even((width * target) / short), even((height * target) / short)];
|
|
67
|
+
}
|
|
68
|
+
const fps = FRAME_RATES[save.frame_rate ?? "original"] ?? Math.max(1, Math.min(60, source.fps));
|
|
69
|
+
return { width, height, fps: round(fps) };
|
|
70
|
+
}
|
|
71
|
+
function format(id = "mp4") {
|
|
72
|
+
const found = formats.find((f) => f.id === id);
|
|
73
|
+
if (!found) {
|
|
74
|
+
throw new OpenCaptionsError(`There is no format ${id}: ${formats.map((f) => f.id).join(", ")}.`);
|
|
75
|
+
}
|
|
76
|
+
return found;
|
|
77
|
+
}
|
|
78
|
+
/** Every family the style draws with: its own, and its word looks'. */
|
|
79
|
+
function families(style) {
|
|
80
|
+
const variants = (style.variants ?? []);
|
|
81
|
+
return [style.font, ...variants.map((v) => v.font ?? "")].filter(Boolean);
|
|
82
|
+
}
|
|
83
|
+
async function saveVideo(project, options, progress) {
|
|
84
|
+
if (!project.transcript)
|
|
85
|
+
throw new OpenCaptionsError("This project has no captions yet.");
|
|
86
|
+
const fmt = format(options.format);
|
|
87
|
+
const info = await probe(project.source);
|
|
88
|
+
const { width, height, fps } = geometry(info, options);
|
|
89
|
+
const style = project.style_config ?? LOOKS[0]?.config;
|
|
90
|
+
for (const family of new Set(families(style)))
|
|
91
|
+
await ensureFont(family);
|
|
92
|
+
const output = options.output ?? `${defaultOutput(project.source, options.captions_only)}${fmt.extension}`;
|
|
93
|
+
const partial = `${output}.part${fmt.extension}`;
|
|
94
|
+
const scene = await Scene.create({
|
|
95
|
+
transcript: project.transcript,
|
|
96
|
+
style,
|
|
97
|
+
width,
|
|
98
|
+
height,
|
|
99
|
+
caption_offset_ms: project.caption_offset_ms,
|
|
100
|
+
});
|
|
101
|
+
try {
|
|
102
|
+
const plan = scene.plan({
|
|
103
|
+
source: project.source,
|
|
104
|
+
output: partial,
|
|
105
|
+
source_transfer: info.transfer,
|
|
106
|
+
duration: project.transcript.duration,
|
|
107
|
+
fps,
|
|
108
|
+
codec: fmt.codec,
|
|
109
|
+
crf: fmt.crf,
|
|
110
|
+
pro_res_profile: fmt.pro_res_profile,
|
|
111
|
+
green_screen: options.captions_only ?? false,
|
|
112
|
+
});
|
|
113
|
+
const [, , bw, bh] = plan.band;
|
|
114
|
+
await encode(plan.args, async (stdin) => {
|
|
115
|
+
let reported = 0;
|
|
116
|
+
// FFmpeg closes its input once it has the length it was asked for: that ends the writing.
|
|
117
|
+
let closed = false;
|
|
118
|
+
stdin.once("close", () => {
|
|
119
|
+
closed = true;
|
|
120
|
+
});
|
|
121
|
+
const room = () => new Promise((done) => {
|
|
122
|
+
const go = () => {
|
|
123
|
+
stdin.off("drain", go);
|
|
124
|
+
stdin.off("close", go);
|
|
125
|
+
done();
|
|
126
|
+
};
|
|
127
|
+
stdin.on("drain", go);
|
|
128
|
+
stdin.on("close", go);
|
|
129
|
+
});
|
|
130
|
+
for (let i = 0; i < plan.frames && !closed; i++) {
|
|
131
|
+
const band = new Uint8Array(bw * bh * 4);
|
|
132
|
+
scene.band(i / fps, plan.band, band);
|
|
133
|
+
if (!stdin.write(band))
|
|
134
|
+
await room();
|
|
135
|
+
const done = (i + 1) / plan.frames;
|
|
136
|
+
if (done - reported >= 0.05) {
|
|
137
|
+
reported = done;
|
|
138
|
+
progress(done, `Saving ${Math.round(done * 100)}%`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
await rename(partial, output);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
await rm(partial, { force: true });
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
scene.free();
|
|
150
|
+
}
|
|
151
|
+
return output;
|
|
152
|
+
}
|
|
153
|
+
async function caption(request, progress) {
|
|
154
|
+
const id = await open(request.source);
|
|
155
|
+
const project = await read(id);
|
|
156
|
+
if (request.look !== undefined || request.style !== undefined) {
|
|
157
|
+
project.style_config = styled(project.style_config, request.look, request.style);
|
|
158
|
+
}
|
|
159
|
+
project.transcript =
|
|
160
|
+
request.transcript ??
|
|
161
|
+
(await transcribe(project.source, { language: request.language, model: request.model }, progress));
|
|
162
|
+
if (project.style_config) {
|
|
163
|
+
project.transcript =
|
|
164
|
+
(await dealt(project.transcript, project.style_config)) ?? project.transcript;
|
|
165
|
+
}
|
|
166
|
+
await write(id, project);
|
|
167
|
+
return { done: true, project_id: id, output: await saveVideo(project, request, progress) };
|
|
168
|
+
}
|
|
169
|
+
export function localCaptioner() {
|
|
170
|
+
return {
|
|
171
|
+
caption: (request, progress) => caption(request, progress),
|
|
172
|
+
resume: async () => {
|
|
173
|
+
throw new OpenCaptionsError("Without a server, every call finishes: there is nothing to resume.");
|
|
174
|
+
},
|
|
175
|
+
setStyle: async (id, look, changes) => {
|
|
176
|
+
const project = await read(id);
|
|
177
|
+
project.style_config = styled(project.style_config, look, changes);
|
|
178
|
+
if (look) {
|
|
179
|
+
project.transcript =
|
|
180
|
+
(await dealt(project.transcript ?? undefined, project.style_config)) ??
|
|
181
|
+
project.transcript;
|
|
182
|
+
}
|
|
183
|
+
await write(id, project);
|
|
184
|
+
return project.style_config;
|
|
185
|
+
},
|
|
186
|
+
readTranscript: async (id) => {
|
|
187
|
+
const project = await read(id);
|
|
188
|
+
return viewTranscript(project.transcript, project.style_config, project.caption_offset_ms);
|
|
189
|
+
},
|
|
190
|
+
fixWords: async (id, fixes) => {
|
|
191
|
+
const project = await read(id);
|
|
192
|
+
project.transcript = await applyFixes(project.transcript, fixes);
|
|
193
|
+
await write(id, project);
|
|
194
|
+
return fixes.length;
|
|
195
|
+
},
|
|
196
|
+
save: async (id, options, progress) => {
|
|
197
|
+
const output = await saveVideo(await read(id), options, progress);
|
|
198
|
+
return { done: true, project_id: id, output };
|
|
199
|
+
},
|
|
200
|
+
exportSubtitles: async (id, kind, output) => {
|
|
201
|
+
const project = await read(id);
|
|
202
|
+
if (!project.transcript)
|
|
203
|
+
throw new OpenCaptionsError("This project has no captions yet.");
|
|
204
|
+
const stem = output ?? defaultOutput(project.source);
|
|
205
|
+
const path = extname(stem) === `.${kind}` ? stem : `${stem}.${kind}`;
|
|
206
|
+
const body = kind === "json"
|
|
207
|
+
? `${JSON.stringify(project.transcript, null, 2)}\n`
|
|
208
|
+
: await subtitles(project.transcript, project.style_config?.words_per_line ?? 3, project.caption_offset_ms, kind);
|
|
209
|
+
await writeFile(path, body);
|
|
210
|
+
return path;
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FFmpeg on this computer, for captioning without a server: what a video is, its sound as the
|
|
3
|
+
* speech model takes it, and the run that makes the captioned file (`encode::plan`'s command, with
|
|
4
|
+
* the caption band piped in). The probe reads what `apps/api/app/services/audio.py` reads.
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { OpenCaptionsError } from "../client.js";
|
|
8
|
+
const INSTALL = "FFmpeg is needed to caption without a server. Install it (macOS: brew install ffmpeg; " +
|
|
9
|
+
"Debian or Ubuntu: sudo apt install ffmpeg; Windows: winget install ffmpeg), or set " +
|
|
10
|
+
"OPENCAPTIONS_URL and OPENCAPTIONS_KEY to use an OpenCaptions server.";
|
|
11
|
+
/** A program's output, or a clear error when it cannot be started or fails. */
|
|
12
|
+
function run(program, args, input) {
|
|
13
|
+
return new Promise((done, fail) => {
|
|
14
|
+
const child = spawn(program, args, { stdio: [input ? "pipe" : "ignore", "pipe", "pipe"] });
|
|
15
|
+
const out = [];
|
|
16
|
+
const err = [];
|
|
17
|
+
child.stdout?.on("data", (chunk) => out.push(chunk));
|
|
18
|
+
child.stderr?.on("data", (chunk) => err.push(chunk));
|
|
19
|
+
child.on("error", (error) => fail(error.code === "ENOENT" ? new OpenCaptionsError(INSTALL) : error));
|
|
20
|
+
child.on("close", (code) => {
|
|
21
|
+
if (code === 0)
|
|
22
|
+
return done(Buffer.concat(out));
|
|
23
|
+
const why = Buffer.concat(err).toString().trim().split("\n").slice(-3).join(" ");
|
|
24
|
+
fail(new OpenCaptionsError(`${program} failed: ${why || `exit code ${code}`}`));
|
|
25
|
+
});
|
|
26
|
+
if (input && child.stdin) {
|
|
27
|
+
// FFmpeg closes its input once it has the length it was asked for: that ends the writing.
|
|
28
|
+
child.stdin.on("error", () => { });
|
|
29
|
+
input(child.stdin).then(() => child.stdin?.end(), (error) => {
|
|
30
|
+
child.kill();
|
|
31
|
+
fail(error);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const rate = (value) => {
|
|
37
|
+
const [n, d = "1"] = String(value ?? "").split("/");
|
|
38
|
+
const fps = Number(n) / Number(d);
|
|
39
|
+
return Number.isFinite(fps) && fps > 0 ? fps : undefined;
|
|
40
|
+
};
|
|
41
|
+
export async function probe(path) {
|
|
42
|
+
const raw = await run("ffprobe", [
|
|
43
|
+
"-v",
|
|
44
|
+
"error",
|
|
45
|
+
"-print_format",
|
|
46
|
+
"json",
|
|
47
|
+
"-show_streams",
|
|
48
|
+
"-show_format",
|
|
49
|
+
"-select_streams",
|
|
50
|
+
"v:0",
|
|
51
|
+
path,
|
|
52
|
+
]);
|
|
53
|
+
const data = JSON.parse(raw.toString());
|
|
54
|
+
const stream = data.streams?.[0];
|
|
55
|
+
if (!stream?.width || !stream.height) {
|
|
56
|
+
throw new OpenCaptionsError(`${path} has no picture FFmpeg can read.`);
|
|
57
|
+
}
|
|
58
|
+
const matrix = stream.side_data_list?.find((s) => s.side_data_type === "Display Matrix");
|
|
59
|
+
const turn = ((Number(matrix?.rotation ?? stream.tags?.rotate ?? 0) % 360) + 360) % 360 || 0;
|
|
60
|
+
const [width, height] = turn === 90 || turn === 270 ? [stream.height, stream.width] : [stream.width, stream.height];
|
|
61
|
+
return {
|
|
62
|
+
width,
|
|
63
|
+
height,
|
|
64
|
+
fps: rate(stream.avg_frame_rate) ?? rate(stream.r_frame_rate) ?? 30,
|
|
65
|
+
duration: Number(stream.duration ?? data.format?.duration ?? 0),
|
|
66
|
+
transfer: stream.color_transfer ?? "",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** The sound as 16 kHz mono samples between -1 and 1, what the speech model takes. */
|
|
70
|
+
export async function audioSamples(path) {
|
|
71
|
+
const raw = await run("ffmpeg", [
|
|
72
|
+
"-v",
|
|
73
|
+
"error",
|
|
74
|
+
"-i",
|
|
75
|
+
path,
|
|
76
|
+
"-vn",
|
|
77
|
+
"-ac",
|
|
78
|
+
"1",
|
|
79
|
+
"-ar",
|
|
80
|
+
"16000",
|
|
81
|
+
"-f",
|
|
82
|
+
"f32le",
|
|
83
|
+
"pipe:1",
|
|
84
|
+
]);
|
|
85
|
+
return new Float32Array(raw.buffer, raw.byteOffset, Math.floor(raw.byteLength / 4));
|
|
86
|
+
}
|
|
87
|
+
/** Runs FFmpeg with `args`, writing what `frames` gives to its input. */
|
|
88
|
+
export async function encode(args, frames) {
|
|
89
|
+
await run("ffmpeg", args, frames);
|
|
90
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcription on this computer: Parakeet Ultra, the server's own default model and the same
|
|
3
|
+
* pinned files, run by sherpa-onnx. The audio is cut into windows and the model's pieces made into
|
|
4
|
+
* timed words exactly as the server does it (`apps/api/app/transcription/chunking.py` and
|
|
5
|
+
* `sherpa.py`); the Swift app has its own copy too, and all three pass the shared
|
|
6
|
+
* `Fixtures/token-words.json`.
|
|
7
|
+
*/
|
|
8
|
+
import { createWriteStream } from "node:fs";
|
|
9
|
+
import { mkdir, rename, rm, stat } from "node:fs/promises";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import { availableParallelism } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { Readable } from "node:stream";
|
|
14
|
+
import { pipeline } from "node:stream/promises";
|
|
15
|
+
import { OpenCaptionsError } from "../client.js";
|
|
16
|
+
import { cacheDir } from "./cache.js";
|
|
17
|
+
import { audioSamples } from "./media.js";
|
|
18
|
+
const SAMPLE_RATE = 16_000;
|
|
19
|
+
/** Loudness is judged on 50 ms frames. */
|
|
20
|
+
const FRAME = SAMPLE_RATE / 20;
|
|
21
|
+
/** The part of a window, from its start, in which the cut is looked for. */
|
|
22
|
+
const CUT_FROM = 0.6;
|
|
23
|
+
/** Below this mean level (dBFS) a window is silence for the retry's purposes. */
|
|
24
|
+
const QUIET_DB = -55;
|
|
25
|
+
/** The shortest piece an empty window is split into. */
|
|
26
|
+
const MIN_RETRY_S = 4;
|
|
27
|
+
const WINDOW_S = 20;
|
|
28
|
+
const SENTENCE_END = [".", "?", "!", "…", "。", "?", "!"];
|
|
29
|
+
/** The model, as `apps/api/app/services/asr_models.py` pins it. */
|
|
30
|
+
const MODEL = {
|
|
31
|
+
id: "parakeet-ultra",
|
|
32
|
+
repo: "leogaudin/sherpa-onnx-nemo-parakeet-ultra-int8",
|
|
33
|
+
revision: "f7bc472677ff70be95cdb0ef9df509fee6f5ec0b",
|
|
34
|
+
files: ["encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt"],
|
|
35
|
+
languages: new Set("bg hr cs da nl en et fi fr de el hu it lv lt mt pl pt ro ru sk sl es sv uk".split(" ")),
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* SentencePiece pieces grouped into timed words: a leading space (or ▁) starts a word, and so does
|
|
39
|
+
* a piece of nothing but that mark; empty and special pieces (`<blank>`) are skipped. A word's
|
|
40
|
+
* confidence is the geometric mean of its pieces' probabilities.
|
|
41
|
+
*/
|
|
42
|
+
export function wordsFromTokens(tokens, starts, durations, logProbs) {
|
|
43
|
+
const words = [];
|
|
44
|
+
let boundary = true;
|
|
45
|
+
tokens.forEach((token, index) => {
|
|
46
|
+
const piece = token.replaceAll("▁", " ");
|
|
47
|
+
const text = piece.trim();
|
|
48
|
+
if (!text || (text.startsWith("<") && text.endsWith(">"))) {
|
|
49
|
+
boundary ||= piece.startsWith(" ");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const start = starts[index] ?? 0;
|
|
53
|
+
const end = start + (durations[index] ?? 0);
|
|
54
|
+
const logProb = logProbs[index] ?? 0;
|
|
55
|
+
const last = words.at(-1);
|
|
56
|
+
if (boundary || piece.startsWith(" ") || !last) {
|
|
57
|
+
words.push({ text, start, end, probs: [logProb] });
|
|
58
|
+
boundary = false;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
last.text += text;
|
|
62
|
+
last.end = end;
|
|
63
|
+
last.probs.push(logProb);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
return words.map(({ text, start, end, probs }) => {
|
|
67
|
+
const from = Math.max(0, start);
|
|
68
|
+
const confidence = Math.exp(probs.reduce((a, b) => a + b, 0) / probs.length);
|
|
69
|
+
return [text, from, Math.max(end, from), Math.min(1, Math.max(0, confidence))];
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/** Every word ends before the next one starts (windows can overlap by a piece). */
|
|
73
|
+
export function separate(words) {
|
|
74
|
+
for (let i = 0; i + 1 < words.length; i++) {
|
|
75
|
+
const [word, next] = [words[i], words[i + 1]];
|
|
76
|
+
if (word[2] > next[1])
|
|
77
|
+
word[2] = Math.max(word[1], next[1]);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** The level of every 50 ms frame, in dBFS. */
|
|
81
|
+
function frameLevels(samples) {
|
|
82
|
+
const levels = new Float64Array(Math.floor(samples.length / FRAME));
|
|
83
|
+
for (let f = 0; f < levels.length; f++) {
|
|
84
|
+
let sum = 0;
|
|
85
|
+
for (let i = f * FRAME; i < (f + 1) * FRAME; i++)
|
|
86
|
+
sum += (samples[i] ?? 0) ** 2;
|
|
87
|
+
levels[f] = 10 * Math.log10(sum / FRAME + 1e-12);
|
|
88
|
+
}
|
|
89
|
+
return levels;
|
|
90
|
+
}
|
|
91
|
+
/** The frame in [first, last) with the lowest level; the latest one if several tie. */
|
|
92
|
+
function quietestCut(levels, first, last) {
|
|
93
|
+
let best = first;
|
|
94
|
+
for (let f = first; f < last; f++)
|
|
95
|
+
if ((levels[f] ?? 0) <= (levels[best] ?? 0))
|
|
96
|
+
best = f;
|
|
97
|
+
return best;
|
|
98
|
+
}
|
|
99
|
+
/** Consecutive [start, end) sample ranges covering the audio, none longer than `maxS`. */
|
|
100
|
+
export function windows(samples, maxS) {
|
|
101
|
+
const longest = Math.floor((maxS * SAMPLE_RATE) / FRAME);
|
|
102
|
+
const levels = frameLevels(samples);
|
|
103
|
+
const out = [];
|
|
104
|
+
let start = 0;
|
|
105
|
+
while (start < samples.length) {
|
|
106
|
+
const endFrame = Math.floor(start / FRAME) + longest;
|
|
107
|
+
if (endFrame * FRAME >= samples.length) {
|
|
108
|
+
out.push([start, samples.length]);
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
const first = Math.floor(start / FRAME) + Math.max(1, Math.floor(longest * CUT_FROM));
|
|
112
|
+
const cut = quietestCut(levels, first, endFrame) * FRAME;
|
|
113
|
+
out.push([start, cut]);
|
|
114
|
+
start = cut;
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
function isQuiet(samples) {
|
|
119
|
+
const levels = frameLevels(samples);
|
|
120
|
+
return levels.length === 0 || levels.reduce((a, b) => a + b, 0) / levels.length < QUIET_DB;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Words for a window, timed from the start of the audio. A window that yields nothing though it
|
|
124
|
+
* is not quiet is split at its quietest point near the middle and each half decoded alone.
|
|
125
|
+
*/
|
|
126
|
+
export function decodeWindow(decode, samples, offset) {
|
|
127
|
+
const words = decode(samples).map(([text, start, end, confidence]) => [text, offset + start, offset + end, confidence]);
|
|
128
|
+
if (words.length || samples.length < 2 * MIN_RETRY_S * SAMPLE_RATE || isQuiet(samples)) {
|
|
129
|
+
return words;
|
|
130
|
+
}
|
|
131
|
+
const levels = frameLevels(samples);
|
|
132
|
+
const middle = Math.floor(levels.length / 2);
|
|
133
|
+
const spread = Math.max(1, Math.floor(levels.length / 5));
|
|
134
|
+
const cut = quietestCut(levels, middle - spread, middle + spread) * FRAME;
|
|
135
|
+
return [
|
|
136
|
+
...decodeWindow(decode, samples.subarray(0, cut), offset),
|
|
137
|
+
...decodeWindow(decode, samples.subarray(cut), offset + cut / SAMPLE_RATE),
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
/** Segments that end at a sentence's end; the rest of the words form the last one. */
|
|
141
|
+
export function segments(words) {
|
|
142
|
+
const out = [];
|
|
143
|
+
let current = [];
|
|
144
|
+
const close = () => {
|
|
145
|
+
const first = current[0];
|
|
146
|
+
const last = current.at(-1);
|
|
147
|
+
if (!first || !last)
|
|
148
|
+
return;
|
|
149
|
+
out.push({
|
|
150
|
+
id: crypto.randomUUID(),
|
|
151
|
+
start: first[1],
|
|
152
|
+
end: last[2],
|
|
153
|
+
text: current.map((w) => w[0]).join(" "),
|
|
154
|
+
words: current.map(([text, start, end, confidence]) => ({ text, start, end, confidence })),
|
|
155
|
+
});
|
|
156
|
+
current = [];
|
|
157
|
+
};
|
|
158
|
+
for (const word of words) {
|
|
159
|
+
current.push(word);
|
|
160
|
+
if (SENTENCE_END.some((mark) => word[0].endsWith(mark)))
|
|
161
|
+
close();
|
|
162
|
+
}
|
|
163
|
+
close();
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
/** The model's files in the cache, fetched once with progress. Returns the folder. */
|
|
167
|
+
async function modelFolder(progress) {
|
|
168
|
+
const folder = join(cacheDir(), "models", MODEL.id, MODEL.revision);
|
|
169
|
+
await mkdir(folder, { recursive: true });
|
|
170
|
+
for (const [i, file] of MODEL.files.entries()) {
|
|
171
|
+
const path = join(folder, file);
|
|
172
|
+
if (await stat(path).catch(() => undefined))
|
|
173
|
+
continue;
|
|
174
|
+
const url = `https://huggingface.co/${MODEL.repo}/resolve/${MODEL.revision}/${file}`;
|
|
175
|
+
const response = await fetch(url);
|
|
176
|
+
if (!response.ok || !response.body) {
|
|
177
|
+
throw new OpenCaptionsError(`Could not download the speech model (${response.status}).`);
|
|
178
|
+
}
|
|
179
|
+
const size = Number(response.headers.get("content-length")) || 0;
|
|
180
|
+
let got = 0;
|
|
181
|
+
const counted = Readable.fromWeb(response.body);
|
|
182
|
+
counted.on("data", (chunk) => {
|
|
183
|
+
got += chunk.length;
|
|
184
|
+
if (size) {
|
|
185
|
+
progress((i + got / size) / MODEL.files.length, `Downloading the speech model, once (${Math.round(got / 1e6)} of ${Math.round(size / 1e6)} MB)`);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
try {
|
|
189
|
+
await pipeline(counted, createWriteStream(`${path}.part`));
|
|
190
|
+
await rename(`${path}.part`, path);
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
await rm(`${path}.part`, { force: true });
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return folder;
|
|
198
|
+
}
|
|
199
|
+
function recognizer(folder) {
|
|
200
|
+
let sherpa;
|
|
201
|
+
try {
|
|
202
|
+
sherpa = createRequire(import.meta.url)("sherpa-onnx-node");
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
const why = String(error instanceof Error ? error.message : error).split("\n")[0];
|
|
206
|
+
throw new OpenCaptionsError(`sherpa-onnx, which runs the speech model, could not be loaded (${why}). Reinstall the ` +
|
|
207
|
+
"package, or set OPENCAPTIONS_URL and OPENCAPTIONS_KEY to transcribe on an OpenCaptions server.");
|
|
208
|
+
}
|
|
209
|
+
return new sherpa.OfflineRecognizer({
|
|
210
|
+
featConfig: { sampleRate: SAMPLE_RATE, featureDim: 80 },
|
|
211
|
+
modelConfig: {
|
|
212
|
+
transducer: {
|
|
213
|
+
encoder: join(folder, "encoder.int8.onnx"),
|
|
214
|
+
decoder: join(folder, "decoder.int8.onnx"),
|
|
215
|
+
joiner: join(folder, "joiner.int8.onnx"),
|
|
216
|
+
},
|
|
217
|
+
tokens: join(folder, "tokens.txt"),
|
|
218
|
+
modelType: "nemo_transducer",
|
|
219
|
+
numThreads: availableParallelism(),
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
export async function transcribe(source, request, progress) {
|
|
224
|
+
if (request.model && request.model !== MODEL.id) {
|
|
225
|
+
throw new OpenCaptionsError(`Without a server, the speech model is ${MODEL.id}. For ${request.model}, set ` +
|
|
226
|
+
"OPENCAPTIONS_URL and OPENCAPTIONS_KEY to use an OpenCaptions server.");
|
|
227
|
+
}
|
|
228
|
+
const language = request.language ?? "auto";
|
|
229
|
+
if (language !== "auto" && !MODEL.languages.has(language)) {
|
|
230
|
+
throw new OpenCaptionsError(`${MODEL.id} does not transcribe ${language}: it covers 25 European languages. Use an ` +
|
|
231
|
+
"OpenCaptions server with a Whisper model for this one.");
|
|
232
|
+
}
|
|
233
|
+
const folder = await modelFolder(progress);
|
|
234
|
+
progress(0, "Reading the sound");
|
|
235
|
+
const samples = await audioSamples(source);
|
|
236
|
+
const model = recognizer(folder);
|
|
237
|
+
const decode = (window) => {
|
|
238
|
+
const stream = model.createStream();
|
|
239
|
+
stream.acceptWaveform({ sampleRate: SAMPLE_RATE, samples: window });
|
|
240
|
+
model.decode(stream);
|
|
241
|
+
const r = model.getResult(stream);
|
|
242
|
+
return wordsFromTokens(r.tokens, r.timestamps, r.durations, r.ys_log_probs);
|
|
243
|
+
};
|
|
244
|
+
const duration = samples.length / SAMPLE_RATE;
|
|
245
|
+
const words = [];
|
|
246
|
+
for (const [start, end] of windows(samples, WINDOW_S)) {
|
|
247
|
+
for (const word of decodeWindow(decode, samples.subarray(start, end), start / SAMPLE_RATE)) {
|
|
248
|
+
words.push([word[0], word[1], Math.min(word[2], duration), word[3]]);
|
|
249
|
+
}
|
|
250
|
+
progress(end / samples.length, `Transcribed ${(end / SAMPLE_RATE).toFixed(1)}s of ${duration.toFixed(1)}s`);
|
|
251
|
+
}
|
|
252
|
+
separate(words);
|
|
253
|
+
return {
|
|
254
|
+
schema_version: 1,
|
|
255
|
+
language: language === "auto" ? "en" : language,
|
|
256
|
+
language_detection: language === "auto" ? "auto" : "manual",
|
|
257
|
+
duration,
|
|
258
|
+
segments: segments(words),
|
|
259
|
+
};
|
|
260
|
+
}
|
package/dist/mcp.js
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
7
7
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
8
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { captionerFromEnv } from "./captioner.js";
|
|
10
|
+
import { OpenCaptionsError } from "./client.js";
|
|
11
11
|
import { describeLook, describeStyle, LOOKS, SAVE_SCHEMA, STYLE_SCHEMA } from "./looks.js";
|
|
12
12
|
import { VERSION } from "./version.js";
|
|
13
13
|
/** How long a tool waits for the server before handing back a ticket, in seconds. */
|
|
@@ -26,7 +26,10 @@ const saveProperties = {
|
|
|
26
26
|
description: "Where to write the file. Default: next to the source, named <name>.captioned",
|
|
27
27
|
},
|
|
28
28
|
};
|
|
29
|
-
const project = {
|
|
29
|
+
const project = {
|
|
30
|
+
type: "string",
|
|
31
|
+
description: "The project id a caption_video call returned (on this computer, a file's path)",
|
|
32
|
+
};
|
|
30
33
|
const TOOLS = [
|
|
31
34
|
{
|
|
32
35
|
name: "caption_video",
|
|
@@ -52,7 +55,8 @@ const TOOLS = [
|
|
|
52
55
|
language: { type: "string", description: "ISO 639-1 code, or auto (the default)" },
|
|
53
56
|
model: {
|
|
54
57
|
type: "string",
|
|
55
|
-
description: "A speech model id the server offers; its default if left out"
|
|
58
|
+
description: "A speech model id the server offers; its default if left out. Without a server, " +
|
|
59
|
+
"parakeet-ultra only",
|
|
56
60
|
},
|
|
57
61
|
...saveProperties,
|
|
58
62
|
},
|
|
@@ -171,45 +175,40 @@ function saveArgs(args) {
|
|
|
171
175
|
output: args.output,
|
|
172
176
|
};
|
|
173
177
|
}
|
|
174
|
-
async function call(
|
|
178
|
+
async function call(captioner, name, args, progress) {
|
|
175
179
|
const deadline = Date.now() + WAIT_S * 1000;
|
|
176
180
|
const id = args.project_id;
|
|
177
181
|
switch (name) {
|
|
178
182
|
case "caption_video":
|
|
179
|
-
return outcome(await
|
|
183
|
+
return outcome(await captioner.caption({ ...args, ...saveArgs(args) }, progress, deadline));
|
|
180
184
|
case "check_progress":
|
|
181
|
-
return outcome(await resume(
|
|
185
|
+
return outcome(await captioner.resume(args.ticket, progress, deadline));
|
|
182
186
|
case "list_looks":
|
|
183
187
|
return said(LOOKS.map(describeLook).join("\n"));
|
|
184
188
|
case "describe_style":
|
|
185
189
|
return said(describeStyle());
|
|
186
190
|
case "get_transcript":
|
|
187
|
-
return said("Captions as shown, then each word with its index:", await readTranscript(
|
|
191
|
+
return said("Captions as shown, then each word with its index:", await captioner.readTranscript(id));
|
|
188
192
|
case "fix_words":
|
|
189
|
-
return said(`Fixed ${await fixWords(
|
|
193
|
+
return said(`Fixed ${await captioner.fixWords(id, args.fixes)} words. Call save_video to write the video again.`);
|
|
190
194
|
case "set_style": {
|
|
191
|
-
const style = await setStyle(
|
|
195
|
+
const style = await captioner.setStyle(id, args.look, args.style);
|
|
192
196
|
return said("The style is set. Call save_video to write the video again.", style);
|
|
193
197
|
}
|
|
194
|
-
case "save_video":
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
return
|
|
198
|
-
}
|
|
199
|
-
case "export_subtitles": {
|
|
200
|
-
const kind = args.format;
|
|
201
|
-
const output = args.output ?? defaultOutput((await getProject(client, id)).title);
|
|
202
|
-
return said(`Wrote ${await exportSubtitles(client, id, kind, output)}`);
|
|
203
|
-
}
|
|
198
|
+
case "save_video":
|
|
199
|
+
return outcome(await captioner.save(id, saveArgs(args), progress, deadline));
|
|
200
|
+
case "export_subtitles":
|
|
201
|
+
return said(`Wrote ${await captioner.exportSubtitles(id, args.format, args.output)}`);
|
|
204
202
|
default:
|
|
205
203
|
throw new OpenCaptionsError(`There is no tool called ${name}.`);
|
|
206
204
|
}
|
|
207
205
|
}
|
|
208
|
-
export function createServer(connect =
|
|
206
|
+
export function createServer(connect = captionerFromEnv) {
|
|
209
207
|
const server = new Server({ name: "opencaptions", version: VERSION }, {
|
|
210
208
|
capabilities: { tools: {} },
|
|
211
|
-
instructions: "OpenCaptions adds animated word-by-word captions to videos, on
|
|
212
|
-
"server. caption_video does it in one call; the other tools
|
|
209
|
+
instructions: "OpenCaptions adds animated word-by-word captions to videos, on this computer or on the " +
|
|
210
|
+
"user's own OpenCaptions server. caption_video does it in one call; the other tools " +
|
|
211
|
+
"adjust the result.",
|
|
213
212
|
});
|
|
214
213
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
215
214
|
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
@@ -223,7 +222,7 @@ export function createServer(connect = () => api(connectionFromEnv())) {
|
|
|
223
222
|
});
|
|
224
223
|
};
|
|
225
224
|
try {
|
|
226
|
-
return await call(connect(), request.params.name, request.params.arguments ?? {}, progress);
|
|
225
|
+
return await call(await connect(), request.params.name, request.params.arguments ?? {}, progress);
|
|
227
226
|
}
|
|
228
227
|
catch (error) {
|
|
229
228
|
const message = error instanceof Error ? error.message : String(error);
|
|
Binary file
|