@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.
Files changed (39) hide show
  1. package/SUBTITLES.md +10 -2
  2. package/dist/adapters/alignment/cache.js +36 -9
  3. package/dist/adapters/alignment/index.js +16 -4
  4. package/dist/adapters/alignment/prefetch.js +11 -6
  5. package/dist/edge/http/settings.js +6 -1
  6. package/dist/edge/mcp/tools-setup.js +2 -2
  7. package/dist/extension/slopify-studio-chrome.zip +0 -0
  8. package/dist/extension/slopify-studio-firefox.zip +0 -0
  9. package/dist/kernel/ports/subtitles.js +6 -0
  10. package/dist/main.js +6 -2
  11. package/dist/patch-notes/3.20.0.md +10 -0
  12. package/dist/patch-notes/3.21.0.md +11 -0
  13. package/dist/patch-notes/index.json +12 -0
  14. package/dist/slices/admission/schema.js +2 -1
  15. package/dist/slices/narration/breaths-detect.js +51 -21
  16. package/dist/slices/narration/breaths-model.js +21 -8
  17. package/dist/slices/narration/breaths.js +15 -13
  18. package/dist/slices/narration/clean-pieces.js +69 -0
  19. package/dist/slices/play-drafts/convert.js +3 -2
  20. package/dist/slices/play-drafts/schema.js +3 -2
  21. package/dist/slices/project-templates/from-project.js +1 -0
  22. package/dist/slices/rebuild/preview-details.js +2 -1
  23. package/dist/slices/rebuild/recipe-breaths.js +21 -11
  24. package/dist/slices/rebuild/runtime-local.js +13 -12
  25. package/dist/slices/settings/model.js +2 -0
  26. package/dist/slices/settings/playback.js +11 -0
  27. package/dist/tutorials/Editing-a-Project.md +3 -2
  28. package/dist/tutorials/FAQ.md +1 -1
  29. package/dist/tutorials/Install.md +3 -3
  30. package/dist/tutorials/Other-Languages.md +1 -1
  31. package/dist/tutorials/Play-Video-and-Style.md +1 -1
  32. package/dist/tutorials/Settings-Reference.md +9 -1
  33. package/dist/tutorials/Troubleshooting.md +1 -1
  34. package/dist/tutorials/Video-Editing.md +1 -1
  35. package/dist/web/assets/index-wwRT6LH3.js +326 -0
  36. package/dist/web/assets/{pdf-Dxrlh3NL.js → pdf-DH2f4NNx.js} +1 -1
  37. package/dist/web/index.html +1 -1
  38. package/package.json +1 -1
  39. 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 an approximately 95 MB English speech model; later runs work offline with the cached model. Allow roughly 1 GB of available memory during alignment. No extra API key, Python, or compiler is needed.
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`. The quantized model is 95,286,046 bytes, SHA256 `cd5040c147381580ed73258143dd8e0c28e800a09e74ee42ee2b3e8cb4d760a3`; every cached/downloaded model is verified before use.
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
- export const alignmentModel = {
10
- filename: "wav2vec2-base-960h-cd5040c1.onnx",
11
- url: `https://huggingface.co/Xenova/wav2vec2-base-960h/resolve/${revision}/onnx/model_quantized.onnx`,
12
- bytes: 95_286_046,
13
- sha256: "cd5040c147381580ed73258143dd8e0c28e800a09e74ee42ee2b3e8cb4d760a3",
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("The free subtitle model download was interrupted after 3 attempts. Check your connection and retry.", { cause: lastFailure });
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 and try again.`);
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. Please try again.");
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 { alignmentModel, prepareModel } from "./cache.js";
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
- export const alignSubtitles = async (request) => {
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: english ? alignmentModel : multilingualModel, fetch: globalThis.fetch });
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 { prepareModel } from "./cache.js";
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 never waits
6
- // on a 95 MB download in the middle of a run. It takes the worker lock, so a render that
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 }, deps);
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 ?? readSettings(playback).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 loudness.",
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",
@@ -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 { alignSubtitles } from "./adapters/alignment/index.js";
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, alignSubtitles: aligner = alignSubtitles, }) {
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
- // Remove breaths (`narration/breaths-model.ts`); absent is off.
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 Remove breaths (`breaths-model.ts` says why). It reads 16-bit mono
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. Each breath is muted to -60 dB with 5 ms fades inside its own stretch: a fade outside
12
- // it once landed on the next word's first sound.
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 ones of the prototype Gent
15
- // approved by ear, on a narration whose median voiced frame is -21.03 dB.
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
- const referenceSpeech = -21.03;
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
- const mutedGain = 10 ** (-60 / 20);
31
- const fadeSamples = 240; // 5 ms
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 findBreaths(chunks) {
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 chunk starting `offset` samples into the piece with the stretches muted; a new array.
40
- export function muteChunk(chunk, offset, stretches) {
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
- const fade = Math.min(fadeSamples, Math.floor((stretch.end - stretch.start) / 2));
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 fade === 1 ? 1 : 1 + ((mutedGain - 1) * into) / (fade - 1);
74
+ return 1 + ((softGain - 1) * into) / (fade - 1);
60
75
  const out = at - (stretch.end - fade);
61
76
  if (out >= 0)
62
- return fade === 1 ? 1 : mutedGain + ((1 - mutedGain) * out) / (fade - 1);
63
- return mutedGain;
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 { count, db, crossings, centroid } = features;
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
- stretches.push({ start: a * hop, end: (b - 1) * hop + frameLength });
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
- // Remove breaths. Some voices (Inworld's TTS-2 among them) take a loud, audible in-breath after
2
- // almost every comma and full stop: about a third of a second, only 12 to 15 dB under the
3
- // words. Switched on, the join finds those breaths in every narration piece and mutes them
4
- // (`breaths.ts`, `breaths-detect.ts`): nothing is cut out, so every word and its timing stay
5
- // exactly where they were, and the pauses between sentences (`pauses-model.ts`) are planned
6
- // from the pieces as spoken just the same. It happens in the join, so the plain narration the
7
- // word timing reads and the levelled one the video plays lose the same breaths.
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 it was, and the joins keep their fingerprints then.
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
+ }