@gentbajko/slopify 3.20.0 → 3.22.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 (37) 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.21.0.md +11 -0
  12. package/dist/patch-notes/3.22.0.md +12 -0
  13. package/dist/patch-notes/index.json +12 -0
  14. package/dist/slices/admission/schema.js +1 -0
  15. package/dist/slices/admission/start.js +2 -0
  16. package/dist/slices/narration/pronunciation-chunks.js +2 -2
  17. package/dist/slices/narration/pronunciation.js +95 -25
  18. package/dist/slices/rebuild/recipe-audio-parts.js +3 -3
  19. package/dist/slices/rebuild/recipe-audio.js +2 -1
  20. package/dist/slices/rebuild/recipe-text.js +2 -2
  21. package/dist/slices/rebuild/recipe-voices.js +3 -3
  22. package/dist/slices/rebuild/recipe-work.js +4 -4
  23. package/dist/slices/settings/model.js +2 -0
  24. package/dist/slices/settings/playback.js +11 -0
  25. package/dist/tutorials/FAQ.md +1 -1
  26. package/dist/tutorials/Install.md +3 -3
  27. package/dist/tutorials/Other-Languages.md +1 -1
  28. package/dist/tutorials/Play-Video-and-Style.md +1 -1
  29. package/dist/tutorials/Settings-Reference.md +9 -1
  30. package/dist/tutorials/Troubleshooting.md +1 -1
  31. package/dist/tutorials/Video-Editing.md +1 -1
  32. package/dist/web/assets/{index-BKbZIxAq.css → index-BadsoORk.css} +1 -1
  33. package/dist/web/assets/index-X6JyTe2r.js +326 -0
  34. package/dist/web/assets/{pdf-tyJWWiKA.js → pdf-B00spAHg.js} +1 -1
  35. package/dist/web/index.html +2 -2
  36. package/package.json +1 -1
  37. package/dist/web/assets/index-DNPct8pl.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,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.
@@ -0,0 +1,12 @@
1
+ # Slopify 3.22.0
2
+
3
+ Released 13 October 2026.
4
+
5
+ - **Names keep their pronunciation in possessives.** "Gygax's", "Lolth's" and every other possessive of a name in the Pronunciation Glossary were read without the glossary's pronunciation. They now get it, with the right ending sound added.
6
+ - **Hyphenated names work in the glossary.** Names like "Arach-Tinilith" or "Catti-brie" with one pronunciation per part were refused; they are now used.
7
+ - **Blank glossary entries are no longer reported as errors.** When the article leaves a name's pronunciation blank ("—"), the Pronunciation tab now says how many names are read as written, instead of listing them as broken entries.
8
+ - **A project's page no longer scrolls into an empty screen.** On wide screens, scrolling outside the columns of a project (most of all on Video) could slide the whole page up past its content.
9
+
10
+ ## Upgrading from 3.21.0
11
+
12
+ - The new glossary rules apply to projects made from now on. Projects already made keep the old rules, so nothing already voiced becomes outdated.
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "id": "3.22.0",
4
+ "title": "Slopify 3.22.0",
5
+ "version": "3.22.0",
6
+ "date": "2026-10-13"
7
+ },
8
+ {
9
+ "id": "3.21.0",
10
+ "title": "Slopify 3.21.0",
11
+ "version": "3.21.0",
12
+ "date": "2026-10-12"
13
+ },
2
14
  {
3
15
  "id": "3.20.0",
4
16
  "title": "Slopify 3.20.0",
@@ -70,6 +70,7 @@ export const runDraftSchema = z.object({
70
70
  })
71
71
  .optional(),
72
72
  narrationAliases: narrationAliasesSchema.optional(),
73
+ pronunciationRules: z.union([z.literal(1), z.literal(2)]).optional(),
73
74
  sharedGlossary: z
74
75
  .array(z.object({
75
76
  term: z.string().min(1).max(200),
@@ -5,6 +5,7 @@ import { resolveChannelId, setProjectChannel } from "../channels/repo.js";
5
5
  import { defaultDocumentTheme } from "../document/model.js";
6
6
  import { earlierEpisodesFor } from "../episodes/related.js";
7
7
  import { listNarrationAliases } from "../narration/aliases-library.js";
8
+ import { currentPronunciationRules } from "../narration/pronunciation.js";
8
9
  import { collectSharedGlossary } from "../narration/shared-glossary.js";
9
10
  import { admitInitialRevision } from "../rebuild/runtime-admission.js";
10
11
  import { adoptBaseline } from "../revisions/adopt.js";
@@ -60,6 +61,7 @@ export function startRun(deps, draft, rendered, retainStaged = false, templates)
60
61
  rendered,
61
62
  ...(shared.length === 0 ? {} : { sharedGlossary: shared }),
62
63
  ...(aliases.length === 0 ? {} : { narrationAliases: aliases }),
64
+ pronunciationRules: currentPronunciationRules,
63
65
  ...(earlier === undefined ? {} : { earlierEpisodes: earlier }),
64
66
  };
65
67
  const project = {
@@ -15,7 +15,7 @@ export function chunkingFingerprintOf(chunking) {
15
15
  export function narrationChunkTexts(source, chunking, anchors) {
16
16
  return anchoredChunks(source, chunking, anchors?.chunkingFingerprint === chunkingFingerprintOf(chunking) ? anchors.texts : undefined);
17
17
  }
18
- export function pronunciationChunks(source, chunking, entries, overriddenKeys = new Set(), sources = {}, anchors) {
18
+ export function pronunciationChunks(source, chunking, entries, overriddenKeys = new Set(), sources = {}, anchors, possessives = false) {
19
19
  const occurrences = new Map();
20
20
  const usedKeys = new Set();
21
21
  const nextKey = (text) => {
@@ -67,7 +67,7 @@ export function pronunciationChunks(source, chunking, entries, overriddenKeys =
67
67
  })),
68
68
  ].sort((a, b) => a.start - b.start);
69
69
  let blockedIndex = 0;
70
- const matches = pronunciationMatches(source, entries).filter((match) => {
70
+ const matches = pronunciationMatches(source, entries, possessives).filter((match) => {
71
71
  while ((blocked[blockedIndex]?.end ?? Infinity) <= match.start)
72
72
  blockedIndex++;
73
73
  return (blocked[blockedIndex]?.start ?? Infinity) >= match.end;
@@ -1,6 +1,15 @@
1
1
  import { remark } from "remark";
2
2
  import remarkGfm from "remark-gfm";
3
3
  import { remembered } from "../../kernel/remembered.js";
4
+ export const currentPronunciationRules = 2;
5
+ export function pronunciationRulesOf(config) {
6
+ return config.pronunciationRules ?? 1;
7
+ }
8
+ // "'s" after a name is English; another language's glossary has no such ending to add.
9
+ export function readsPossessives(config) {
10
+ return (pronunciationRulesOf(config) === 2 &&
11
+ (config.language === undefined || config.language === "en"));
12
+ }
4
13
  // Inworld requires English IPA, not every symbol in Unicode's IPA Extensions block.
5
14
  // https://docs.inworld.ai/tts/capabilities/custom-pronunciation
6
15
  const ipaAtom = "[ˈˌ]?[abdefghijklmnoprstuvwxzæðŋθɑɒɔəɚɛɜɝɡɪɹʃʊʌʒʔɫɾ][\\u0303\\u031a\\u0325\\u0329\\u032a\\u032c\\u032f\\u035c\\u0361ʰʲʷ]*[ːˑ]?";
@@ -50,8 +59,29 @@ function termIdentity(term) {
50
59
  }
51
60
  // `language` is the project's (`kernel/ports/languages.ts`): absent or English accepts the
52
61
  // standard-English IPA Inworld asks for, as it always did; any other accepts full IPA.
53
- export const parsePronunciationGlossary = remembered(8, (markdown, language) => `${language ?? ""}\u0000${markdown}`, parseGlossary);
54
- function parseGlossary(markdown, language) {
62
+ export const parsePronunciationGlossary = remembered(8, (markdown, language, rules) => `${rules ?? currentPronunciationRules}\u0000${language ?? ""}\u0000${markdown}`, parseGlossary);
63
+ // A cell the writer left out on purpose: empty, a dash, or "n/a", with or without slashes.
64
+ const blankIpa = /^(?:\/\s*(?:[—–-]+|n\/a)\s*\/|[—–-]+|n\/a)?$/iu;
65
+ const hyphens = /[-\u2010\u2011]/u;
66
+ // One IPA word per written word. A hyphenated name is one written word, so its parts' IPA words
67
+ // are joined with "." (a syllable break) into the one word Inworld wants between a pair of slashes.
68
+ function pairWords(term, ipa) {
69
+ const words = term.split(" ");
70
+ if (words.length === ipa.length)
71
+ return ipa;
72
+ const parts = words.map((word) => word.split(hyphens).filter((part) => part !== "").length || 1);
73
+ if (parts.reduce((sum, count) => sum + count, 0) !== ipa.length)
74
+ return null;
75
+ let next = 0;
76
+ return parts.map((count) => {
77
+ const joined = ipa.slice(next, next + count).join(".");
78
+ next += count;
79
+ return joined;
80
+ });
81
+ }
82
+ function parseGlossary(markdown, language, rules = currentPronunciationRules) {
83
+ const modern = rules === 2;
84
+ let unpronounced = 0;
55
85
  const english = language === undefined || language === "en";
56
86
  const symbols = english ? ipaSymbols : worldSymbols;
57
87
  const kind = english ? "standard-English IPA" : "IPA";
@@ -65,7 +95,7 @@ function parseGlossary(markdown, language) {
65
95
  const trimmed = row.trim();
66
96
  if (trimmed === "" || /^pronunciation glossary:?\s*$/iu.test(trimmed))
67
97
  continue;
68
- const pair = /^([^:]+):\s*(.+)$/u.exec(trimmed);
98
+ const pair = (modern ? /^([^:]+):\s*(.*)$/u : /^([^:]+):\s*(.+)$/u).exec(trimmed);
69
99
  const term = pair?.[1]?.trim().replace(/\s+/gu, " ");
70
100
  const pronunciation = pair?.[2];
71
101
  if (term === undefined ||
@@ -75,6 +105,10 @@ function parseGlossary(markdown, language) {
75
105
  skip("use Term: /IPA/ or a Term | IPA table");
76
106
  continue;
77
107
  }
108
+ if (modern && blankIpa.test(pronunciation.trim())) {
109
+ unpronounced++;
110
+ continue;
111
+ }
78
112
  const notation = /^(\/[^/]+\/(?:\s+\/[^/]+\/)*)(?:\s+[^/]+)?$/u.exec(pronunciation);
79
113
  if (notation?.[1] === undefined) {
80
114
  skip(`use slash-delimited ${kind}`);
@@ -87,37 +121,54 @@ function parseGlossary(markdown, language) {
87
121
  : "use IPA, not ARPAbet or delivery tags");
88
122
  continue;
89
123
  }
90
- if (term.split(" ").length !== ipa.length) {
91
- skip("supply one IPA word for each written word");
124
+ const paired = modern
125
+ ? pairWords(term, ipa)
126
+ : term.split(" ").length === ipa.length
127
+ ? ipa
128
+ : null;
129
+ if (paired === null) {
130
+ skip(modern
131
+ ? "supply one IPA word for each written word, or for each hyphen-separated part of it"
132
+ : "supply one IPA word for each written word");
92
133
  continue;
93
134
  }
94
135
  const key = termIdentity(term);
95
136
  const previous = entries.get(key);
96
- if (previous !== undefined && previous.ipa.join(" ") !== ipa.join(" ")) {
137
+ if (previous !== undefined && previous.ipa.join(" ") !== paired.join(" ")) {
97
138
  skip("an earlier entry already gives this term a different pronunciation");
98
139
  continue;
99
140
  }
100
141
  if (previous === undefined)
101
- entries.set(key, { term, ipa });
142
+ entries.set(key, { term, ipa: paired });
102
143
  }
103
- return skipped.length === 0
104
- ? { ok: true, entries: [...entries.values()] }
105
- : { ok: true, entries: [...entries.values()], skipped };
144
+ return {
145
+ ok: true,
146
+ entries: [...entries.values()],
147
+ ...(unpronounced === 0 ? {} : { unpronounced }),
148
+ ...(skipped.length === 0 ? {} : { skipped }),
149
+ };
106
150
  }
107
- // What a person reads about the skipped rows: where they are and why, never their text.
108
- export function skippedGlossaryNotice(skipped) {
109
- if (skipped.length === 0)
151
+ // What a person reads about the skipped rows: where they are and why, never their text. Names
152
+ // left without a pronunciation on purpose are not errors, so they get one calm line of their own.
153
+ export function skippedGlossaryNotice(skipped, unpronounced = 0) {
154
+ if (skipped.length === 0 && unpronounced === 0)
110
155
  return null;
156
+ const where = "in the Pronunciation Glossary at the end of the article in Edit project → Article";
157
+ const blank = unpronounced === 0
158
+ ? ""
159
+ : `${unpronounced === 1 ? "1 name has" : `${unpronounced} names have`} no pronunciation given and ${unpronounced === 1 ? "is" : "are"} read as written. To give ${unpronounced === 1 ? "it one" : "them one"}, fill in its IPA ${where}.`;
160
+ if (skipped.length === 0)
161
+ return `Pronunciation Glossary: ${blank}`;
111
162
  const rows = skipped.map((one) => `entry ${one.row}: ${one.reason}`).join("; ");
112
163
  return (`Pronunciation Glossary: ${skipped.length === 1 ? "1 entry is" : `${skipped.length} entries are`} ` +
113
164
  `skipped and read as ordinary text (${rows}). The rest of the glossary is used. ` +
114
- "To use them, fix those entries in the Pronunciation Glossary at the end of the article in Edit project → Article.");
165
+ `To use them, fix those entries ${where}.${blank === "" ? "" : ` ${blank}`}`);
115
166
  }
116
- export function skippedSpeakerPronunciations(speakers, language) {
167
+ export function skippedSpeakerPronunciations(speakers, language, rules) {
117
168
  return speakers.flatMap((speaker) => {
118
169
  if (!speaker.pronunciations?.trim())
119
170
  return [];
120
- const parsed = parsePronunciationGlossary(speaker.pronunciations, language);
171
+ const parsed = parsePronunciationGlossary(speaker.pronunciations, language, rules);
121
172
  const skipped = parsed.ok ? (parsed.skipped ?? []) : [];
122
173
  return skipped.length === 0 ? [] : [{ speaker: speaker.name.trim() || "A speaker", skipped }];
123
174
  });
@@ -130,8 +181,8 @@ export function skippedSpeakerPronunciationsNotice(rows) {
130
181
  "The rest are used. To use them, fix those entries under Speakers → Pronunciations for that speaker (Play → Narration, or Edit project → Providers).");
131
182
  }
132
183
  // Remembered (`kernel/remembered.ts`): every plan rebuild matches the whole narration again.
133
- export const pronunciationMatches = remembered(1024, (source, entries) => `${source}\u0000${JSON.stringify(entries)}`, matchPronunciations);
134
- function matchPronunciations(source, entries) {
184
+ export const pronunciationMatches = remembered(1024, (source, entries, possessives) => `${possessives === true ? "p" : "-"}\u0000${source}\u0000${JSON.stringify(entries)}`, matchPronunciations);
185
+ function matchPronunciations(source, entries, possessives = false) {
135
186
  if (entries.length === 0)
136
187
  return [];
137
188
  const ordered = [...entries].sort((a, b) => b.term.length - a.term.length);
@@ -140,11 +191,15 @@ function matchPronunciations(source, entries) {
140
191
  .map((word) => word.replace(/[|\\{}()[\]^$+*?.]/gu, "\\$&"))
141
192
  .join("\\s+"));
142
193
  const boundary = "[\\p{L}\\p{M}\\p{N}_\\-\u00ad\u2010\u2011\ufe63\uff0d]";
194
+ // A bare trailing apostrophe ("Iuz' henchmen") is never a possessive here: it can't be told
195
+ // from a closing quote, and a wrong "z" is worse than a name read as written.
143
196
  const expression = new RegExp("(?<!" +
144
197
  boundary +
145
198
  "['’]?)(?:" +
146
199
  patterns.map((pattern) => `(${pattern})`).join("|") +
147
- ")(?!['’]?" +
200
+ ")" +
201
+ (possessives ? "(['’]s)?" : "") +
202
+ "(?!['’]?" +
148
203
  boundary +
149
204
  ")", "giu");
150
205
  const matches = [];
@@ -157,20 +212,35 @@ function matchPronunciations(source, entries) {
157
212
  const entry = ordered.find((_, index) => match[index + 1] !== undefined);
158
213
  if (entry === undefined)
159
214
  throw new Error("Slopify hit an internal error (a pronunciation glossary term has no pronunciation). Try again; if it happens again, use Download diagnostics in Settings and report it.");
160
- matches.push({ start: match.index, end, entry });
215
+ const possessive = possessives ? match[ordered.length + 1] : undefined;
216
+ matches.push({
217
+ start: match.index,
218
+ end,
219
+ entry,
220
+ ...(possessive === undefined ? {} : { possessive }),
221
+ });
161
222
  }
162
223
  return matches;
163
224
  }
164
- export function pronunciationSpans(source, entries) {
225
+ // The sound a possessive adds to the last sound of the name: "iz" after s z sh zh ch j,
226
+ // "s" after the other voiceless sounds, "z" after everything else.
227
+ function withPossessive(ipa) {
228
+ const last = ipa.replace(/[\u0300-\u036fʰʲʷːˑ]+$/u, "").slice(-1);
229
+ if ("sz\u0283\u0292".includes(last))
230
+ return `${ipa}\u026az`;
231
+ return `${ipa}${"ptkfθ".includes(last) ? "s" : "z"}`;
232
+ }
233
+ export function pronunciationSpans(source, entries, possessives = false) {
165
234
  const spans = [];
166
- for (const match of pronunciationMatches(source, entries)) {
167
- const words = source.slice(match.start, match.end).matchAll(/\S+/gu);
168
- for (const [index, word] of Array.from(words).entries()) {
235
+ for (const match of pronunciationMatches(source, entries, possessives)) {
236
+ const words = Array.from(source.slice(match.start, match.end).matchAll(/\S+/gu));
237
+ for (const [index, word] of words.entries()) {
169
238
  const ipa = match.entry.ipa[index];
170
239
  if (ipa === undefined)
171
240
  throw new Error("Slopify hit an internal error (a pronunciation glossary term is missing a word). Try again; if it happens again, use Download diagnostics in Settings and report it.");
172
241
  const start = match.start + word.index;
173
- spans.push({ start, end: start + word[0].length, text: `/${ipa}/` });
242
+ const spoken = match.possessive !== undefined && index === words.length - 1 ? withPossessive(ipa) : ipa;
243
+ spans.push({ start, end: start + word[0].length, text: `/${spoken}/` });
174
244
  }
175
245
  }
176
246
  return spans;
@@ -2,7 +2,7 @@ import { aliasMatches } from "../../kernel/ports/narration-aliases.js";
2
2
  import { narrationAliasesOf, usesNarrationPreparation, usesPronunciationGlossary, } from "../admission/rules.js";
3
3
  import { aliasSpans, withAliasSpans } from "../narration/aliases.js";
4
4
  import { narrationRegenerationToken, normalizeNarrationText, planNarration, } from "../narration/plan.js";
5
- import { pronunciationSpans } from "../narration/pronunciation.js";
5
+ import { pronunciationSpans, readsPossessives, } from "../narration/pronunciation.js";
6
6
  import { prepareRequests } from "../narration/steering.js";
7
7
  import { recipe } from "./recipe-model.js";
8
8
  import { preparationForGroup } from "./recipe-preparation.js";
@@ -25,7 +25,7 @@ export function narrationParts(context, logicalKey, originalText, segment, depen
25
25
  // Aliases first: where one applies, the glossary does not.
26
26
  const aliases = narrationAliasesOf(context.config);
27
27
  const matches = aliasMatches(logicalText, aliases);
28
- const spans = withAliasSpans(pronunciationSpans(logicalText, glossary.entries), aliasSpans(logicalText, aliases));
28
+ const spans = withAliasSpans(pronunciationSpans(logicalText, glossary.entries, readsPossessives(context.config)), aliasSpans(logicalText, aliases));
29
29
  const wholeRequest = segment !== "body" || (context.config.chunking?.mode ?? "whole") === "whole";
30
30
  const choice = context.config.audio;
31
31
  // A retired model's character limit is still its limit: the automatic catalogue check marks
@@ -128,7 +128,7 @@ export function pronunciationFutureValues(context, glossary, article, logicalKey
128
128
  ],
129
129
  ];
130
130
  const clean = normalizeNarrationText(override?.kind === "text" ? override.text : source);
131
- const spans = pronunciationSpans(clean, glossary.entries);
131
+ const spans = pronunciationSpans(clean, glossary.entries, readsPossessives(context.config));
132
132
  return spans.length === 0
133
133
  ? []
134
134
  : [