@gentbajko/slopify 3.19.0 → 3.21.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/SUBTITLES.md +10 -2
- package/dist/adapters/alignment/cache.js +36 -9
- package/dist/adapters/alignment/index.js +16 -4
- package/dist/adapters/alignment/prefetch.js +11 -6
- package/dist/edge/http/settings.js +6 -1
- package/dist/edge/mcp/tools-setup.js +2 -2
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/kernel/ports/subtitles.js +6 -0
- package/dist/main.js +6 -2
- package/dist/patch-notes/3.20.0.md +10 -0
- package/dist/patch-notes/3.21.0.md +11 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/admission/schema.js +2 -1
- package/dist/slices/narration/breaths-detect.js +51 -21
- package/dist/slices/narration/breaths-model.js +21 -8
- package/dist/slices/narration/breaths.js +15 -13
- package/dist/slices/narration/clean-pieces.js +69 -0
- package/dist/slices/play-drafts/convert.js +3 -2
- package/dist/slices/play-drafts/schema.js +3 -2
- package/dist/slices/project-templates/from-project.js +1 -0
- package/dist/slices/rebuild/preview-details.js +2 -1
- package/dist/slices/rebuild/recipe-breaths.js +21 -11
- package/dist/slices/rebuild/runtime-local.js +13 -12
- package/dist/slices/settings/model.js +2 -0
- package/dist/slices/settings/playback.js +11 -0
- package/dist/tutorials/Editing-a-Project.md +3 -2
- package/dist/tutorials/FAQ.md +1 -1
- package/dist/tutorials/Install.md +3 -3
- package/dist/tutorials/Other-Languages.md +1 -1
- package/dist/tutorials/Play-Video-and-Style.md +1 -1
- package/dist/tutorials/Settings-Reference.md +9 -1
- package/dist/tutorials/Troubleshooting.md +1 -1
- package/dist/tutorials/Video-Editing.md +1 -1
- package/dist/web/assets/index-wwRT6LH3.js +326 -0
- package/dist/web/assets/{pdf-Dxrlh3NL.js → pdf-DH2f4NNx.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-OxnO0Bfh.js +0 -326
package/SUBTITLES.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
1. In Play, enable narration or provide matching English audio and article text.
|
|
4
4
|
2. In Subtitles, choose **Subtitle files (.srt + .vtt)** or **Burn into video + files**.
|
|
5
5
|
3. For burned captions, select a bundled or system font, or upload a `.ttf` or `.otf` file (up to 32 MiB). Choose a size from 16 to 120 and a position: top, upper-middle, center, lower-middle or bottom. The side preview shows the selected font, size and position in a 16:9 or 9:16 frame.
|
|
6
|
-
4. Start the run. Slopify times the article against the actual narration on your computer. The first use downloads
|
|
6
|
+
4. Start the run. Slopify times the article against the actual narration on your computer. The first use downloads the English speech model chosen in Settings → Production defaults → **Subtitle timing model** (378 MB unless changed); later runs work offline with the cached model. Allow roughly 1.2 GB of available memory during alignment (0.7 GB with Smallest). No extra API key, Python, or compiler is needed.
|
|
7
7
|
5. Download SRT/VTT beside the final export. Files mode adds an optional native caption track to the in-app video preview. Burned captions remain visible in the downloaded MP4.
|
|
8
8
|
|
|
9
9
|
For a completed project, open its final Video or Audio export section, set subtitles, and click **Save subtitles**. This rebuilds only the local export from saved narration and images. Changing font, size or position reuses word timing when the audio and spoken text are unchanged. A paused run saves these choices until Resume; pause an active run before editing. Failed alignment or rendering keeps the previous finished export.
|
|
@@ -18,6 +18,14 @@ Fonts are copied into the completed project's caption assets, so an existing exp
|
|
|
18
18
|
|
|
19
19
|
Speech inference uses MIT-licensed `onnxruntime-node@1.30.0` (native CPU) in a separate process, with `min(8, CPUs − 1)` threads; set `SLOPIFY_SUBTITLE_THREADS` to override. The npm package bundles CPU builds for Linux x64/arm64, Windows x64/arm64 and Apple silicon macOS (about 220 MB unpacked); Intel Macs have no build, so subtitle timing falls back to the slower single-threaded `onnxruntime-web` there. Its postinstall script only fetches optional CUDA files on Linux x64 and is not needed (the Docker image installs with `--ignore-scripts`); model weights are downloaded lazily into `<data-dir>/models/english-subtitles/`.
|
|
20
20
|
|
|
21
|
-
Model: [Xenova/wav2vec2-base-960h](https://huggingface.co/Xenova/wav2vec2-base-960h/tree/a19f851b3d42865797e410752b4c570c871e4825), an ONNX conversion of [facebook/wav2vec2-base-960h](https://huggingface.co/facebook/wav2vec2-base-960h), licensed Apache-2.0. Pinned revision: `a19f851b3d42865797e410752b4c570c871e4825`.
|
|
21
|
+
Model: [Xenova/wav2vec2-base-960h](https://huggingface.co/Xenova/wav2vec2-base-960h/tree/a19f851b3d42865797e410752b4c570c871e4825), an ONNX conversion of [facebook/wav2vec2-base-960h](https://huggingface.co/facebook/wav2vec2-base-960h), licensed Apache-2.0. Pinned revision: `a19f851b3d42865797e410752b4c570c871e4825`. Three precisions of that revision can be chosen per computer, each verified before use and kept under its own file name so they can sit side by side:
|
|
22
|
+
|
|
23
|
+
| Setting | File | Bytes | SHA256 |
|
|
24
|
+
|---|---|---|---|
|
|
25
|
+
| Most accurate (default) | `onnx/model.onnx` (fp32) | 377,887,594 | `e46614273f03ff4b87923a965e417fa72004825522cb007c9c25633b8475490d` |
|
|
26
|
+
| Balanced | `onnx/model_fp16.onnx` | 189,192,204 | `378348ee38b739cc77e77e3fb8502f0f40ed53da0dbf7401b24c25c8fafe03de` |
|
|
27
|
+
| Smallest | `onnx/model_quantized.onnx` (int8) | 95,286,046 | `cd5040c147381580ed73258143dd8e0c28e800a09e74ee42ee2b3e8cb4d760a3` |
|
|
28
|
+
|
|
29
|
+
On a 2 h 28 min narration fp32 aligned in 2 min 40 s (1.1 GB peak) and fp16 in 3 min 16 s (1.2 GB), both hearing every line; int8 took 5 min 35 s (0.7 GB) and misheard two lines, stopping the run with a false "Subtitles stopped matching the audio".
|
|
22
30
|
|
|
23
31
|
The bundled Barlow font uses the SIL Open Font License; its license and source record ship in `dist/assets/fonts/`. Uploaded/system font licensing remains with its author.
|
|
@@ -5,13 +5,36 @@ import { join } from "node:path";
|
|
|
5
5
|
import { Readable } from "node:stream";
|
|
6
6
|
import { pipeline } from "node:stream/promises";
|
|
7
7
|
import { setTimeout as delay } from "node:timers/promises";
|
|
8
|
+
import { defaultEnglishSubtitleModel, } from "../../kernel/ports/subtitles.js";
|
|
9
|
+
// Xenova/wav2vec2-base-960h at one pinned revision, which carries the same model at three
|
|
10
|
+
// precisions. Each keeps its own file name so the cache can hold any of them at once; int8
|
|
11
|
+
// keeps the name every install used before the choice existed.
|
|
8
12
|
const revision = "a19f851b3d42865797e410752b4c570c871e4825";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
const repo = `https://huggingface.co/Xenova/wav2vec2-base-960h/resolve/${revision}/onnx`;
|
|
14
|
+
export const englishModels = {
|
|
15
|
+
fp32: {
|
|
16
|
+
filename: "wav2vec2-base-960h-fp32-e4661427.onnx",
|
|
17
|
+
url: `${repo}/model.onnx`,
|
|
18
|
+
bytes: 377_887_594,
|
|
19
|
+
sha256: "e46614273f03ff4b87923a965e417fa72004825522cb007c9c25633b8475490d",
|
|
20
|
+
timeoutMs: 1_800_000,
|
|
21
|
+
},
|
|
22
|
+
fp16: {
|
|
23
|
+
filename: "wav2vec2-base-960h-fp16-378348ee.onnx",
|
|
24
|
+
url: `${repo}/model_fp16.onnx`,
|
|
25
|
+
bytes: 189_192_204,
|
|
26
|
+
sha256: "378348ee38b739cc77e77e3fb8502f0f40ed53da0dbf7401b24c25c8fafe03de",
|
|
27
|
+
timeoutMs: 1_200_000,
|
|
28
|
+
},
|
|
29
|
+
int8: {
|
|
30
|
+
filename: "wav2vec2-base-960h-cd5040c1.onnx",
|
|
31
|
+
url: `${repo}/model_quantized.onnx`,
|
|
32
|
+
bytes: 95_286_046,
|
|
33
|
+
sha256: "cd5040c147381580ed73258143dd8e0c28e800a09e74ee42ee2b3e8cb4d760a3",
|
|
34
|
+
},
|
|
14
35
|
};
|
|
36
|
+
// The default English model, and the one the Docker image ships as its seed.
|
|
37
|
+
export const alignmentModel = englishModels[defaultEnglishSubtitleModel];
|
|
15
38
|
export async function prepareModel(input, deps = { model: alignmentModel, fetch: globalThis.fetch }) {
|
|
16
39
|
input.signal.throwIfAborted();
|
|
17
40
|
await mkdir(input.cacheDir, { recursive: true, mode: 0o700 });
|
|
@@ -38,7 +61,7 @@ export async function prepareModel(input, deps = { model: alignmentModel, fetch:
|
|
|
38
61
|
}
|
|
39
62
|
}
|
|
40
63
|
}
|
|
41
|
-
throw new Error(
|
|
64
|
+
throw new Error(`The free subtitle model download (${megabytes(deps.model.bytes)}) was interrupted after 3 attempts, so the captions could not be timed. Check your internet connection, then Continue the run.${deps.model === alignmentModel || deps.model === englishModels.fp16 ? smaller : ""}`, { cause: lastFailure });
|
|
42
65
|
}
|
|
43
66
|
async function downloadModel(input, deps, target) {
|
|
44
67
|
input.signal.throwIfAborted();
|
|
@@ -58,7 +81,7 @@ async function downloadModel(input, deps, target) {
|
|
|
58
81
|
throw interrupted(error);
|
|
59
82
|
}
|
|
60
83
|
if (!response.ok || response.body === null) {
|
|
61
|
-
const error = new Error(`The free subtitle model could not be downloaded (HTTP ${response.status}). Check your connection
|
|
84
|
+
const error = new Error(`The free subtitle model could not be downloaded (HTTP ${response.status}) from Hugging Face, so the captions could not be timed. Check your internet connection, then Continue the run.`);
|
|
62
85
|
let failure = error;
|
|
63
86
|
try {
|
|
64
87
|
if (response.body !== null)
|
|
@@ -91,7 +114,7 @@ async function downloadModel(input, deps, target) {
|
|
|
91
114
|
break;
|
|
92
115
|
current += result.value.length;
|
|
93
116
|
if (current > deps.model.bytes)
|
|
94
|
-
throw new Error("The subtitle model failed download size verification.");
|
|
117
|
+
throw new Error("The subtitle model failed download size verification: more arrived than the file Slopify expects, so it was thrown away. Continue the run to download it again.");
|
|
95
118
|
hash.update(result.value);
|
|
96
119
|
input.onProgress?.(current, deps.model.bytes);
|
|
97
120
|
yield result.value;
|
|
@@ -112,7 +135,7 @@ async function downloadModel(input, deps, target) {
|
|
|
112
135
|
});
|
|
113
136
|
input.signal.throwIfAborted();
|
|
114
137
|
if (current !== deps.model.bytes || hash.digest("hex") !== deps.model.sha256)
|
|
115
|
-
throw new Error("The subtitle model failed download verification.
|
|
138
|
+
throw new Error("The subtitle model failed download verification: the file that arrived is not the one Slopify expects, so it was thrown away. Continue the run to download it again.");
|
|
116
139
|
// Each caller writes in its own directory. On Windows a completed concurrent download
|
|
117
140
|
// may already own the final filename; reuse it only after the same verification.
|
|
118
141
|
try {
|
|
@@ -146,6 +169,10 @@ async function seedModel(input, seed, target) {
|
|
|
146
169
|
await rm(staging, { recursive: true, force: true });
|
|
147
170
|
}
|
|
148
171
|
}
|
|
172
|
+
const smaller = " To download less, pick a smaller model in Settings → Production defaults → Subtitle timing model.";
|
|
173
|
+
function megabytes(bytes) {
|
|
174
|
+
return `${String(Math.round(bytes / 1_000_000))} MB`;
|
|
175
|
+
}
|
|
149
176
|
function interrupted(cause) {
|
|
150
177
|
const error = new Error("The subtitle model download was interrupted.", { cause });
|
|
151
178
|
error.name = "SubtitleModelDownloadInterrupted";
|
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { languageInfo } from "../../kernel/ports/languages.js";
|
|
4
|
+
import { defaultEnglishSubtitleModel, } from "../../kernel/ports/subtitles.js";
|
|
4
5
|
import { decodeAudio } from "./audio.js";
|
|
5
|
-
import {
|
|
6
|
+
import { englishModels, prepareModel } from "./cache.js";
|
|
6
7
|
import { claimWorker } from "./lock.js";
|
|
7
8
|
import { alignmentSpecFor, multilingualModel } from "./multilingual.js";
|
|
8
9
|
import { runAlignmentWorker } from "./runner.js";
|
|
9
10
|
import { sentenceTiming, speechShape } from "./sentences.js";
|
|
10
|
-
|
|
11
|
+
// The model file that times a language: English with the model chosen in Settings, every other
|
|
12
|
+
// word-timed language with the multilingual one.
|
|
13
|
+
export function modelFor(language, english) {
|
|
14
|
+
return language === undefined || language === "en" ? englishModels[english] : multilingualModel;
|
|
15
|
+
}
|
|
16
|
+
// `chosen` is asked once per alignment, when it starts: an alignment already running keeps the
|
|
17
|
+
// model it opened, and the next one uses whatever Settings says then.
|
|
18
|
+
export function alignSubtitlesWith(chosen) {
|
|
19
|
+
return (request) => align(request, chosen);
|
|
20
|
+
}
|
|
21
|
+
export const alignSubtitles = alignSubtitlesWith(() => defaultEnglishSubtitleModel);
|
|
22
|
+
async function align(request, chosen) {
|
|
11
23
|
request.signal.throwIfAborted();
|
|
12
24
|
if (languageInfo(request.language).timing === "sentences")
|
|
13
25
|
return timeSentences(request);
|
|
@@ -20,7 +32,7 @@ export const alignSubtitles = async (request) => {
|
|
|
20
32
|
cacheDir: request.cacheDir,
|
|
21
33
|
signal: request.signal,
|
|
22
34
|
onProgress: (current, total) => request.onProgress?.(Math.round((current / total) * 20), 100),
|
|
23
|
-
}, { model:
|
|
35
|
+
}, { model: modelFor(request.language, chosen()), fetch: globalThis.fetch });
|
|
24
36
|
request.onProgress?.(20, 100);
|
|
25
37
|
const working = await mkdtemp(join(request.cacheDir, ".alignment-audio-"));
|
|
26
38
|
try {
|
|
@@ -42,7 +54,7 @@ export const alignSubtitles = async (request) => {
|
|
|
42
54
|
finally {
|
|
43
55
|
await release();
|
|
44
56
|
}
|
|
45
|
-
}
|
|
57
|
+
}
|
|
46
58
|
// No model and no download: the narration's loudness says where it pauses, and the sentences
|
|
47
59
|
// are laid over it (`sentences.ts`).
|
|
48
60
|
async function timeSentences(request) {
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import { mkdir, readFile, rm } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { defaultEnglishSubtitleModel, } from "../../kernel/ports/subtitles.js";
|
|
4
|
+
import { englishModels, prepareModel } from "./cache.js";
|
|
4
5
|
import { claimWorker } from "./lock.js";
|
|
5
|
-
// Readies the subtitle model when the app starts, so the first captioned render
|
|
6
|
-
// on a
|
|
7
|
-
// starts meanwhile waits for this copy rather than fetching a second one.
|
|
8
|
-
export async function prefetchModel(input, deps) {
|
|
6
|
+
// Readies the chosen English subtitle model when the app starts, so the first captioned render
|
|
7
|
+
// never waits on a download of up to 378 MB in the middle of a run. It takes the worker lock,
|
|
8
|
+
// so a render that starts meanwhile waits for this copy rather than fetching a second one.
|
|
9
|
+
export async function prefetchModel(input, deps = {}) {
|
|
9
10
|
await mkdir(input.cacheDir, { recursive: true, mode: 0o700 });
|
|
10
11
|
await clearOwnLock(input.cacheDir);
|
|
11
12
|
const release = await claimWorker(input.cacheDir, input.signal);
|
|
12
13
|
try {
|
|
13
|
-
return await prepareModel({ cacheDir: input.cacheDir, seed: input.seed, signal: input.signal },
|
|
14
|
+
return await prepareModel({ cacheDir: input.cacheDir, seed: input.seed, signal: input.signal }, {
|
|
15
|
+
...deps,
|
|
16
|
+
model: deps.model ?? englishModels[input.choice ?? defaultEnglishSubtitleModel],
|
|
17
|
+
fetch: deps.fetch ?? globalThis.fetch,
|
|
18
|
+
});
|
|
14
19
|
}
|
|
15
20
|
finally {
|
|
16
21
|
await release();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { zValidator } from "@hono/zod-validator";
|
|
2
2
|
import { Hono } from "hono";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { englishSubtitleModels } from "../../kernel/ports/subtitles.js";
|
|
4
5
|
import { lastAgentConnection } from "../../slices/agents/connection.js";
|
|
5
6
|
import { recentAgentActions } from "../../slices/agents/repo.js";
|
|
6
7
|
import { notificationUrlMax, notificationUrlProblem, testNotice, webhookBody, } from "../../slices/notifications/address.js";
|
|
@@ -42,6 +43,8 @@ const playbackBody = z.object({
|
|
|
42
43
|
loudness: z
|
|
43
44
|
.object({ enabled: z.boolean(), videoLufs: z.number(), audioFilesLufs: z.number() })
|
|
44
45
|
.optional(),
|
|
46
|
+
// Absent keeps what is saved, as with loudness.
|
|
47
|
+
subtitleModel: z.enum(englishSubtitleModels).optional(),
|
|
45
48
|
});
|
|
46
49
|
// The return type is inferred so Hono keeps the route types the SPA's client is
|
|
47
50
|
// generated from; see stagingRoutes.
|
|
@@ -53,9 +56,11 @@ export function settingsRoutes(deps) {
|
|
|
53
56
|
.get("/", (c) => c.json(readSettings(playback)))
|
|
54
57
|
.put("/", zValidator("json", playbackBody, onInvalid), (c) => {
|
|
55
58
|
const body = c.req.valid("json");
|
|
59
|
+
const saved = readSettings(playback);
|
|
56
60
|
const result = saveSettings(playback, {
|
|
57
61
|
...body,
|
|
58
|
-
loudness: body.loudness ??
|
|
62
|
+
loudness: body.loudness ?? saved.loudness,
|
|
63
|
+
subtitleModel: body.subtitleModel ?? saved.subtitleModel,
|
|
59
64
|
});
|
|
60
65
|
if (!result.ok) {
|
|
61
66
|
return problem(c, {
|
|
@@ -52,13 +52,13 @@ const settings = [
|
|
|
52
52
|
name: "get_settings",
|
|
53
53
|
route: "GET /api/settings",
|
|
54
54
|
title: "Read production defaults",
|
|
55
|
-
description: "Settings → Production defaults: silence gap, appearance and
|
|
55
|
+
description: "Settings → Production defaults: silence gap, appearance, loudness and the English subtitle timing model.",
|
|
56
56
|
},
|
|
57
57
|
{
|
|
58
58
|
name: "update_settings",
|
|
59
59
|
route: "PUT /api/settings",
|
|
60
60
|
title: "Save production defaults",
|
|
61
|
-
description: "Body: {silenceGapSeconds, appearance: system | light | dark, loudness?: {enabled, videoLufs, audioFilesLufs}}.",
|
|
61
|
+
description: "Body: {silenceGapSeconds, appearance: system | light | dark, loudness?: {enabled, videoLufs, audioFilesLufs}, subtitleModel?: fp32 | fp16 | int8}. subtitleModel is this computer's English caption-timing model (fp32 378 MB most accurate, the default; fp16 189 MB; int8 95 MB, less accurate); it never marks subtitles outdated.",
|
|
62
62
|
},
|
|
63
63
|
{
|
|
64
64
|
name: "get_default_channel_links",
|
|
Binary file
|
|
Binary file
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
// The English timing model, chosen per computer in Settings → Production defaults: the same
|
|
2
|
+
// pinned wav2vec2 conversion at three precisions. Bigger hears more accurately and, on most
|
|
3
|
+
// computers, also runs faster. The choice never enters a fingerprint: every precision times the
|
|
4
|
+
// same words, so switching must not mark finished subtitles outdated.
|
|
5
|
+
export const englishSubtitleModels = ["fp32", "fp16", "int8"];
|
|
6
|
+
export const defaultEnglishSubtitleModel = "fp32";
|
|
1
7
|
// Where the audio stopped matching its transcript: seconds into the aligned file, the
|
|
2
8
|
// transcript words it expected there and what the model heard instead (either may be empty).
|
|
3
9
|
// The aligner throws it so the caller can say which part of the narration to redo.
|
package/dist/main.js
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import { serve } from "@hono/node-server";
|
|
6
6
|
import ffmpegStatic from "ffmpeg-static";
|
|
7
7
|
import { buildRegistry } from "./adapter-registry.js";
|
|
8
|
-
import {
|
|
8
|
+
import { alignSubtitlesWith } from "./adapters/alignment/index.js";
|
|
9
9
|
import { prefetchModel } from "./adapters/alignment/prefetch.js";
|
|
10
10
|
import { prepareFfmpeg } from "./adapters/ffmpeg.js";
|
|
11
11
|
import { createHostCliClient } from "./adapters/host-cli/index.js";
|
|
@@ -72,6 +72,7 @@ import { settleTerminalScheduleRuns } from "./slices/schedules/repo.js";
|
|
|
72
72
|
import { createScheduleRunner } from "./slices/schedules/scheduler.js";
|
|
73
73
|
import { nodeCliProbe } from "./slices/settings/cli-status.js";
|
|
74
74
|
import { isLocalCliProvider, localCliConcurrency } from "./slices/settings/model.js";
|
|
75
|
+
import { readSettings } from "./slices/settings/playback.js";
|
|
75
76
|
import { providerStatuses } from "./slices/settings/readiness.js";
|
|
76
77
|
import { readSetting } from "./slices/settings/repo.js";
|
|
77
78
|
import { arrangeChanged } from "./slices/storage/arrange.js";
|
|
@@ -692,6 +693,7 @@ export async function boot(config, options = {}) {
|
|
|
692
693
|
? prefetchModel({
|
|
693
694
|
cacheDir: subtitleModelDir(paths.dataDir),
|
|
694
695
|
seed: options.subtitleModelSeed,
|
|
696
|
+
choice: readSettings({ db, log }).subtitleModel,
|
|
695
697
|
signal: modelPrefetch.signal,
|
|
696
698
|
}).then(() => log.write("info", "subtitle-model.ready"), (error) => {
|
|
697
699
|
// Not fatal: the first captioned render tries again and says why on its row.
|
|
@@ -762,7 +764,9 @@ export async function boot(config, options = {}) {
|
|
|
762
764
|
throw error;
|
|
763
765
|
}
|
|
764
766
|
}
|
|
765
|
-
export function wireRunner({ onFinished, audioPreviews, db, paths, clock, ids, log, hub, telemetry, flusher, registry, catalogue, ffmpeg,
|
|
767
|
+
export function wireRunner({ onFinished, audioPreviews, db, paths, clock, ids, log, hub, telemetry, flusher, registry, catalogue, ffmpeg,
|
|
768
|
+
// Settings → Production defaults → Subtitle timing model, read as each alignment starts.
|
|
769
|
+
alignSubtitles: aligner = alignSubtitlesWith(() => readSettings({ db, log }).subtitleModel), }) {
|
|
766
770
|
// A stage counts what it did and the queue is flushed after each new event. `record`
|
|
767
771
|
// swallows its own failures, so this can neither fail a stage nor widen what leaves the
|
|
768
772
|
// machine - the payload allow-list is checked inside it.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Slopify 3.20.0
|
|
2
|
+
|
|
3
|
+
Released 11 October 2026.
|
|
4
|
+
|
|
5
|
+
- **Remove breaths is now Soften breaths, and it no longer clips words.** 3.19.0 muted every breath it found to silence. On a full-length narration some of those stretches held the edge of a word, which came out clipped ("Highs" as "Hi…s"), and the drops to silence were abrupt. Breaths are now turned down 12 dB with gentle fades, and any stretch with a word sound in it is left alone. See `docs/breaths.md`.
|
|
6
|
+
- **Soften S sounds.** A new switch under Soften breaths (Play → Video and style, Edit project → Pauses and volume) takes the sharp edge off s and sh sounds with a very light de-esser. It is off by default and works with or without Soften breaths.
|
|
7
|
+
|
|
8
|
+
## Upgrading from 3.19.0
|
|
9
|
+
|
|
10
|
+
- A project that had Remove breaths on joins its narration again once, from the pieces it already has. No speech is made again. If you used Remove breaths, remake those videos.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Slopify 3.21.0
|
|
2
|
+
|
|
3
|
+
Released 13 October 2026.
|
|
4
|
+
|
|
5
|
+
- **Captions are timed with a more accurate speech model by default.** The old 95 MB model sometimes misheard a line of narration, stopped the run with "Subtitles stopped matching the audio" and recorded a good narration chunk again at a cost. The full-precision version of the same model hears those lines correctly. On a 2½-hour narration it was also faster: 2 min 40 s instead of 5 min 35 s.
|
|
6
|
+
- **Choose the subtitle timing model** in Settings → Production defaults: Most accurate (378 MB, the default), Balanced (189 MB) or Smallest (95 MB, less accurate). The choice belongs to this computer and downloads once, the first time captions are timed.
|
|
7
|
+
|
|
8
|
+
## Upgrading from 3.20.0
|
|
9
|
+
|
|
10
|
+
- The first video you make downloads the 378 MB model once. To keep the smaller model, choose Smallest in Settings → Production defaults.
|
|
11
|
+
- Nothing becomes outdated: the model choice doesn't change any word timings already made.
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"id": "3.21.0",
|
|
4
|
+
"title": "Slopify 3.21.0",
|
|
5
|
+
"version": "3.21.0",
|
|
6
|
+
"date": "2026-10-12"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "3.20.0",
|
|
10
|
+
"title": "Slopify 3.20.0",
|
|
11
|
+
"version": "3.20.0",
|
|
12
|
+
"date": "2026-10-11"
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"id": "3.19.0",
|
|
4
16
|
"title": "Slopify 3.19.0",
|
|
@@ -185,8 +185,9 @@ export const runDraftSchema = z.object({
|
|
|
185
185
|
// Pauses between sentences and paragraphs (`narration/pauses-model.ts`); checked by admission.
|
|
186
186
|
sentencePauseSeconds: z.number().optional(),
|
|
187
187
|
paragraphPauseSeconds: z.number().optional(),
|
|
188
|
-
//
|
|
188
|
+
// Soften breaths and Soften S sounds (`narration/breaths-model.ts`); absent is off.
|
|
189
189
|
removeBreaths: z.boolean().optional(),
|
|
190
|
+
deEss: z.boolean().optional(),
|
|
190
191
|
// Absent is English (`kernel/ports/languages.ts`).
|
|
191
192
|
language: languageSchema.optional(),
|
|
192
193
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { setImmediate as nextTurn } from "node:timers/promises";
|
|
2
|
-
// The breath detector behind
|
|
2
|
+
// The breath detector behind Soften breaths (`breaths-model.ts` says why). It reads 16-bit mono
|
|
3
3
|
// PCM at 48 kHz in 20 ms frames, 10 ms apart, and gives each frame three numbers: its level
|
|
4
4
|
// (RMS in dB), how often it crosses zero (noise crosses often, a voiced vowel seldom) and its
|
|
5
5
|
// spectral centroid (where its sound sits: an in-breath low, an "s" or "f" high).
|
|
@@ -8,15 +8,22 @@ import { setImmediate as nextTurn } from "node:timers/promises";
|
|
|
8
8
|
// 2. A run of breath-like frames 140 ms or longer is a breath. It grows back over the quiet
|
|
9
9
|
// frames before it until silence, a voiced word's tail or a fricative, and forward over the
|
|
10
10
|
// quiet after it until silence or the next word's voice.
|
|
11
|
-
// 3.
|
|
12
|
-
//
|
|
11
|
+
// 3. A stretch whose loudest 10 ms (RMS over its non-overlapping 10 ms blocks) is above -26 dB
|
|
12
|
+
// at the calibration (8.74 dB under the voice's level) holds a word sound, and is left alone. 3.19.0 muted those too
|
|
13
|
+
// and clipped word edges ("Highs" came out "Hi…s").
|
|
14
|
+
// 4. Each other breath is turned down 12 dB, with 45 ms linear fades inside its own stretch (a
|
|
15
|
+
// fade outside it once landed on the next word's first sound). Silencing it, as 3.19.0 did,
|
|
16
|
+
// dropped to nothing abruptly; a stretch of 90 ms or less is left as it is.
|
|
13
17
|
// Levels are relative to the voice's own: the median level of its voiced frames. A quieter or
|
|
14
|
-
// louder voice finds the same breaths. The thresholds are the
|
|
15
|
-
// approved by ear
|
|
18
|
+
// louder voice finds the same breaths. The thresholds are the prototype's absolute ones, which
|
|
19
|
+
// Gent approved by ear on the finished video's audio: its median voiced frame (read as
|
|
20
|
+
// `speechLevel` reads it, over the whole 2.5 h video) is -17.26 dB. The levelled pieces the
|
|
21
|
+
// join works on sit about 4 dB lower, and every threshold moves down with them.
|
|
16
22
|
export const breathRate = 48_000;
|
|
17
23
|
const frameLength = 960; // 20 ms
|
|
18
24
|
const hop = 480; // 10 ms
|
|
19
|
-
|
|
25
|
+
// Calibrated on the finished video's audio, which is what was approved by ear.
|
|
26
|
+
const referenceSpeech = -17.26;
|
|
20
27
|
const breathCeiling = -22;
|
|
21
28
|
const breathFloor = -55;
|
|
22
29
|
const voicedFloor = -42;
|
|
@@ -27,23 +34,31 @@ const breathFrames = 14; // 140 ms
|
|
|
27
34
|
// A voiced frame for the reference: under the noisy crossing rate and within 30 dB of the
|
|
28
35
|
// voice's loud frames (the 95th percentile).
|
|
29
36
|
const voicedSpan = 30;
|
|
30
|
-
|
|
31
|
-
const
|
|
37
|
+
// A stretch with a 10 ms block louder than this (at the calibration) is left alone.
|
|
38
|
+
const wordCeiling = -26;
|
|
39
|
+
const softGain = 10 ** (-12 / 20);
|
|
40
|
+
const fadeSamples = 2160; // 45 ms
|
|
32
41
|
// ceiling: 200 frames (2 s of audio, about 15 ms of work) between yields, so a long piece never
|
|
33
42
|
// holds the server's event loop long enough for the page to notice.
|
|
34
43
|
const framesPerTurn = 200;
|
|
35
|
-
// Where the breaths are in a piece, read once from its PCM in order.
|
|
36
|
-
export async function
|
|
44
|
+
// Where the breaths are in a piece, and which hold a word sound, read once from its PCM in order.
|
|
45
|
+
export async function scanBreaths(chunks) {
|
|
37
46
|
return breathsIn(await featuresOf(chunks));
|
|
38
47
|
}
|
|
39
|
-
// The
|
|
40
|
-
export function
|
|
48
|
+
// The breaths to soften in a piece.
|
|
49
|
+
export async function findBreaths(chunks) {
|
|
50
|
+
return (await scanBreaths(chunks)).stretches;
|
|
51
|
+
}
|
|
52
|
+
// The chunk starting `offset` samples into the piece with the stretches turned down; a new array.
|
|
53
|
+
export function softenChunk(chunk, offset, stretches) {
|
|
41
54
|
const out = Int16Array.from(chunk);
|
|
42
55
|
const end = offset + chunk.length;
|
|
43
56
|
for (const stretch of stretches) {
|
|
44
57
|
if (stretch.end <= offset || stretch.start >= end)
|
|
45
58
|
continue;
|
|
46
|
-
|
|
59
|
+
if (stretch.end - stretch.start <= 2 * fadeSamples)
|
|
60
|
+
continue;
|
|
61
|
+
const fade = fadeSamples;
|
|
47
62
|
const from = Math.max(stretch.start, offset);
|
|
48
63
|
const to = Math.min(stretch.end, end);
|
|
49
64
|
for (let at = from; at < to; at += 1) {
|
|
@@ -56,17 +71,18 @@ export function muteChunk(chunk, offset, stretches) {
|
|
|
56
71
|
function gainAt(stretch, fade, at) {
|
|
57
72
|
const into = at - stretch.start;
|
|
58
73
|
if (into < fade)
|
|
59
|
-
return
|
|
74
|
+
return 1 + ((softGain - 1) * into) / (fade - 1);
|
|
60
75
|
const out = at - (stretch.end - fade);
|
|
61
76
|
if (out >= 0)
|
|
62
|
-
return
|
|
63
|
-
return
|
|
77
|
+
return softGain + ((1 - softGain) * out) / (fade - 1);
|
|
78
|
+
return softGain;
|
|
64
79
|
}
|
|
65
80
|
async function featuresOf(chunks) {
|
|
66
81
|
let capacity = 4096;
|
|
67
82
|
let db = new Float32Array(capacity);
|
|
68
83
|
let crossings = new Float32Array(capacity);
|
|
69
84
|
let centroid = new Float32Array(capacity);
|
|
85
|
+
let blockDb = new Float32Array(capacity + 1);
|
|
70
86
|
let count = 0;
|
|
71
87
|
const work = fftWorkspace();
|
|
72
88
|
const frame = new Float64Array(frameLength);
|
|
@@ -84,6 +100,7 @@ async function featuresOf(chunks) {
|
|
|
84
100
|
db = grown(db, capacity);
|
|
85
101
|
crossings = grown(crossings, capacity);
|
|
86
102
|
centroid = grown(centroid, capacity);
|
|
103
|
+
blockDb = grown(blockDb, capacity + 1);
|
|
87
104
|
}
|
|
88
105
|
const start = count * hop - base;
|
|
89
106
|
for (let at = 0; at < frameLength; at += 1)
|
|
@@ -91,6 +108,9 @@ async function featuresOf(chunks) {
|
|
|
91
108
|
db[count] = levelOf(frame);
|
|
92
109
|
crossings[count] = crossingsOf(frame);
|
|
93
110
|
centroid[count] = powerSpectrumCentroid(frame, work);
|
|
111
|
+
// A frame's halves are blocks count and count + 1; the next frame reads the second again.
|
|
112
|
+
blockDb[count] = levelOf(frame.subarray(0, hop));
|
|
113
|
+
blockDb[count + 1] = levelOf(frame.subarray(hop));
|
|
94
114
|
count += 1;
|
|
95
115
|
if (count % framesPerTurn === 0)
|
|
96
116
|
await nextTurn();
|
|
@@ -99,7 +119,7 @@ async function featuresOf(chunks) {
|
|
|
99
119
|
carry = pending.slice(keep);
|
|
100
120
|
base += keep;
|
|
101
121
|
}
|
|
102
|
-
return { count, db, crossings, centroid };
|
|
122
|
+
return { count, db, crossings, centroid, blockDb };
|
|
103
123
|
}
|
|
104
124
|
function grown(values, capacity) {
|
|
105
125
|
const next = new Float32Array(capacity);
|
|
@@ -109,13 +129,14 @@ function grown(values, capacity) {
|
|
|
109
129
|
function breathsIn(features) {
|
|
110
130
|
const speech = speechLevel(features);
|
|
111
131
|
if (speech === undefined)
|
|
112
|
-
return [];
|
|
132
|
+
return { stretches: [], skipped: [] };
|
|
113
133
|
const level = (prototype) => prototype - referenceSpeech + speech;
|
|
114
134
|
const ceiling = level(breathCeiling);
|
|
115
135
|
const floor = level(breathFloor);
|
|
116
136
|
const voicedAbove = level(voicedFloor);
|
|
117
137
|
const silentBelow = level(silenceFloor);
|
|
118
|
-
const
|
|
138
|
+
const wordAbove = level(wordCeiling);
|
|
139
|
+
const { count, db, crossings, centroid, blockDb } = features;
|
|
119
140
|
const at = (values, index) => values[index] ?? 0;
|
|
120
141
|
const voiced = (index) => at(db, index) > voicedAbove && at(crossings, index) < noisyCrossings;
|
|
121
142
|
const hit = (index) => at(crossings, index) >= noisyCrossings &&
|
|
@@ -123,6 +144,14 @@ function breathsIn(features) {
|
|
|
123
144
|
at(db, index) < ceiling &&
|
|
124
145
|
at(centroid, index) < breathCentroid;
|
|
125
146
|
const stretches = [];
|
|
147
|
+
const skipped = [];
|
|
148
|
+
// Blocks a to b: the stretch from a * hop to (b - 1) * hop + frameLength = (b + 1) * hop.
|
|
149
|
+
const holdsWord = (a, b) => {
|
|
150
|
+
for (let block = a; block <= b; block += 1)
|
|
151
|
+
if (at(blockDb, block) > wordAbove)
|
|
152
|
+
return true;
|
|
153
|
+
return false;
|
|
154
|
+
};
|
|
126
155
|
let i = 0;
|
|
127
156
|
while (i < count) {
|
|
128
157
|
if (!hit(i)) {
|
|
@@ -142,11 +171,12 @@ function breathsIn(features) {
|
|
|
142
171
|
let b = j;
|
|
143
172
|
while (b < count && at(db, b) > silentBelow && !voiced(b))
|
|
144
173
|
b += 1;
|
|
145
|
-
|
|
174
|
+
const stretch = { start: a * hop, end: (b - 1) * hop + frameLength };
|
|
175
|
+
(holdsWord(a, b) ? skipped : stretches).push(stretch);
|
|
146
176
|
}
|
|
147
177
|
i = j;
|
|
148
178
|
}
|
|
149
|
-
return stretches;
|
|
179
|
+
return { stretches, skipped };
|
|
150
180
|
}
|
|
151
181
|
// The voice's own level: the median of its voiced frames, those under the noisy crossing rate
|
|
152
182
|
// within 30 dB of its loud frames. Undefined for a piece without a voiced frame.
|
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// words. Switched on, the join finds those
|
|
4
|
-
// (`breaths.ts`, `breaths-detect.ts`)
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
1
|
+
// Soften breaths (the config keeps its first name, `removeBreaths`). Some voices (Inworld's
|
|
2
|
+
// TTS-2 among them) take a loud, audible in-breath after almost every comma and full stop:
|
|
3
|
+
// about a third of a second, only 12 to 15 dB under the words. Switched on, the join finds those
|
|
4
|
+
// breaths in every narration piece and turns them down 12 dB (`breaths.ts`, `breaths-detect.ts`),
|
|
5
|
+
// leaving alone any stretch with a word sound in it: nothing is cut out, so every word and its
|
|
6
|
+
// timing stay exactly where they were, and the pauses between sentences (`pauses-model.ts`) are
|
|
7
|
+
// planned from the pieces as spoken just the same. It happens in the join, so the plain narration
|
|
8
|
+
// the word timing reads and the levelled one the video plays lose the same breaths.
|
|
9
|
+
//
|
|
10
|
+
// Soften S sounds (`deEss`): each narration piece in the join goes through ffmpeg's de-esser,
|
|
11
|
+
// set very light (Gent approved these values by ear), after the breaths and before the pauses.
|
|
8
12
|
//
|
|
9
13
|
// Browser-safe: Play and Edit project read it. Off is absent from the config, which is what
|
|
10
|
-
// every project saved before
|
|
14
|
+
// every project saved before them was, and the joins keep their fingerprints then.
|
|
11
15
|
export function breathsOn(config) {
|
|
12
16
|
return config.removeBreaths === true;
|
|
13
17
|
}
|
|
@@ -15,3 +19,12 @@ export function breathsOn(config) {
|
|
|
15
19
|
export function breathsOfForm(form) {
|
|
16
20
|
return form.removeBreaths === true ? { removeBreaths: true } : {};
|
|
17
21
|
}
|
|
22
|
+
// ffmpeg's `deesser` options: intensity, most de-essing, frequency (each 0 to 1).
|
|
23
|
+
export const deEsser = { intensity: 0.35, max: 0.5, frequency: 0.5 };
|
|
24
|
+
export function deEssOn(config) {
|
|
25
|
+
return config.deEss === true;
|
|
26
|
+
}
|
|
27
|
+
// Play's switch as a run's config holds it: only on is written.
|
|
28
|
+
export function deEssOfForm(form) {
|
|
29
|
+
return form.deEss === true ? { deEss: true } : {};
|
|
30
|
+
}
|