@gentbajko/slopify 4.1.0 → 4.3.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 CHANGED
@@ -12,6 +12,8 @@ If narration omits a short phrase, alignment can skip that transcript span when
12
12
 
13
13
  Audio Off disables subtitles. Video Off produces WAV audio with separate subtitle files. Uploaded audio must match the article; substantial mismatches fail with a message to correct the transcript. Review timing and spelling before publishing. English, Spanish, German, French, Italian, Portuguese, Dutch, Catalan, Polish and Czech are timed word by word (the other nine with a separate 248 MB multilingual model, downloaded on first use into `<data-dir>/models/multilingual-subtitles/`, about 1.1 GB of memory while aligning); other project languages are timed sentence by sentence. See docs/multilingual-timing.md. Unusual pronunciations and passages in another language than the project's can fail alignment.
14
14
 
15
+ Timing also records, word by word, the letters the model heard where they are not the word it was given to place. Nothing extra is computed for it and nothing changes about the captions: it is the decode the aligner already runs to check the audio against the transcript, kept per word instead of discarded. It is what lets the project page answer whether a name was said right rather than merely said clearly, and it is why `subtitles.json` carries a `heard` field on some words and `listened: true` at the top. Captions timed by an earlier version have neither, and the name check reports those as unlistened rather than right.
16
+
15
17
  Fonts are copied into the completed project's caption assets, so an existing export can reuse its chosen font even after the original system font is removed. Installed fonts and uploaded fonts stay local. SRT/VTT are portable text/timing files and do not embed a font; the chosen font is used in burned video captions.
16
18
 
17
19
  ## Local model and licenses
@@ -32,6 +32,7 @@ export const workerMessage = z.discriminatedUnion("type", [
32
32
  start: z.number().finite().nonnegative(),
33
33
  end: z.number().finite().nonnegative(),
34
34
  confidence: z.number().finite().min(0).max(1).optional(),
35
+ heard: z.string().max(200).optional(),
35
36
  })),
36
37
  }),
37
38
  omissionSchema.extend({ type: z.literal("omission") }),
@@ -152,9 +152,7 @@ export function saidTheirWay(logits, frames, candidate, complete, cutoff, spec,
152
152
  }
153
153
  if (keep < holdingWords)
154
154
  return undefined;
155
- return words
156
- .slice(0, keep)
157
- .map((word, index) => (matches[index] ? word : { ...word, confidence: 0 }));
155
+ return withHeard(logits, frames, words.slice(0, keep), candidate, spec).map((word, index) => matches[index] ? word : { ...word, confidence: 0 });
158
156
  }
159
157
  function accepted(logits, frames, candidate, complete, cutoff, spec, maximumError = spec.gates.maximumError) {
160
158
  const words = alignWindow(logits, frames, candidate, complete, spec).words.filter((word) => word.end <= cutoff);
@@ -165,13 +163,13 @@ function accepted(logits, frames, candidate, complete, cutoff, spec, maximumErro
165
163
  .map((word) => word.spoken)
166
164
  .join(" "), greedy(logits, Math.min(frames, Math.ceil(last.end / frameSeconds)), spec), maximumError, spec.comparable))
167
165
  throw new Error(spec.mismatch);
168
- return words;
166
+ return withHeard(logits, frames, words, candidate, spec);
169
167
  }
170
168
  export function greedy(logits, frames, spec = englishSpec) {
171
169
  return greedyBetween(logits, 0, frames, spec);
172
170
  }
173
171
  // What the model heard between two frames.
174
- function greedyBetween(logits, from, to, spec = englishSpec) {
172
+ export function greedyBetween(logits, from, to, spec = englishSpec) {
175
173
  const labels = spec.labels;
176
174
  let previous = -1;
177
175
  let text = "";
@@ -186,3 +184,29 @@ function greedyBetween(logits, from, to, spec = englishSpec) {
186
184
  }
187
185
  return text.trim().replace(/\s+/g, " ");
188
186
  }
187
+ // What the model heard over one word's own frames, when that is not the word it was given to
188
+ // place. Forced alignment is only ever asked where a word is, never which word it was, so a
189
+ // name said wrong the same way every time scores as well as one said right; these letters are
190
+ // the one place the difference shows. Kept only on a word that disagrees, so the timing file
191
+ // stays a timing file: a word the model heard as written carries nothing.
192
+ export function heardAs(logits, frames, word, expected, spec) {
193
+ // Too short to tell apart: "the" heard as "th" says nothing about anything.
194
+ if (spec.lettersOnly(expected).length < 3)
195
+ return undefined;
196
+ const heard = greedyBetween(logits, Math.floor(word.start / frameSeconds), Math.min(frames, Math.ceil(word.end / frameSeconds)), spec);
197
+ // Anything that is not the word itself, not only what is far from it: "Loth" for "Lolth" is
198
+ // one letter out and is exactly the mistake worth catching. Whether a difference means
199
+ // anything is for the reader to judge from how often it repeats, not for one word to decide,
200
+ // so this records and the name check counts (`slices/narration/pronunciation-check.ts`).
201
+ const letters = spec.comparable(heard);
202
+ if (letters === "" || letters === spec.comparable(expected))
203
+ return undefined;
204
+ return heard.slice(0, 60);
205
+ }
206
+ // Every word of an accepted window, carrying what the model heard wherever that disagrees.
207
+ export function withHeard(logits, frames, words, candidate, spec) {
208
+ return words.map((word, index) => {
209
+ const heard = heardAs(logits, frames, word, candidate[index]?.spoken ?? "", spec);
210
+ return heard === undefined ? word : { ...word, heard };
211
+ });
212
+ }
@@ -39,6 +39,7 @@ import { problem, problemFromError } from "./problem.js";
39
39
  import { projectTemplateRoutes } from "./project-templates.js";
40
40
  import { projectRoutes } from "./projects.js";
41
41
  import { promptRoutes } from "./prompts.js";
42
+ import { pronunciationCheckRoutes } from "./pronunciation-check.js";
42
43
  import { pronunciationRoutes } from "./pronunciations.js";
43
44
  import { providerRoutes } from "./providers.js";
44
45
  import { remoteAccessRoutes } from "./remote-access.js";
@@ -103,6 +104,7 @@ function apiRoutes(deps, startedAt, runAllowed) {
103
104
  .route("/projects", openFolderRoutes(deps))
104
105
  .route("/projects", audioPreviewRoutes(deps))
105
106
  .route("/projects", narrationPeakRoutes(deps))
107
+ .route("/projects", pronunciationCheckRoutes(deps))
106
108
  .route("/projects", runCostRoutes(deps))
107
109
  .route("/projects", uploadedRoutes(deps))
108
110
  .route("/home", homeRoutes(deps))
@@ -0,0 +1,138 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { zValidator } from "@hono/zod-validator";
3
+ import { Hono } from "hono";
4
+ import { usesPronunciationGlossary } from "../../slices/admission/rules.js";
5
+ import { splitEndMatter } from "../../slices/article/split.js";
6
+ import { pronunciationRulesOf, readsPossessives } from "../../slices/narration/pronunciation.js";
7
+ import { heardNames, namesAhead, namesAheadNotice, namesNarrated, narratedNotice, } from "../../slices/narration/pronunciation-check.js";
8
+ import { wordsSchema } from "../../slices/rebuild/runtime-subtitles.js";
9
+ import { currentRevisionId } from "../../slices/revisions/repo.js";
10
+ import { getRevisionView, isReadyOutput, readyOutput } from "../../slices/revisions/view.js";
11
+ import { outputPath } from "../../slices/storage/layout.js";
12
+ import { projectTitle } from "../../slices/storage/repo.js";
13
+ import { projectIdParam } from "./ids.js";
14
+ import { onInvalid, problem } from "./problem.js";
15
+ function sourcesOf(config, view) {
16
+ const glossary = usesPronunciationGlossary(config) && view.articleMarkdown !== null
17
+ ? splitEndMatter(view.articleMarkdown).glossary
18
+ : "";
19
+ return {
20
+ glossary,
21
+ ...(config.libraryPronunciations === undefined ? {} : { list: config.libraryPronunciations }),
22
+ ...(config.audio?.shareGlossary === true && config.sharedGlossary !== undefined
23
+ ? { shared: config.sharedGlossary }
24
+ : {}),
25
+ ...(config.language === undefined ? {} : { language: config.language }),
26
+ rules: pronunciationRulesOf(config),
27
+ };
28
+ }
29
+ // Every segment of one kind of narration file, in spoken order, joined: the intro, the body and
30
+ // the outro are one piece of narration for counting.
31
+ function narrationText(deps, projectId, view, role) {
32
+ const order = (segment) => segment === "intro" ? 0 : segment === "outro" ? 2 : 1;
33
+ const parts = view.outputs
34
+ .filter((row) => isReadyOutput(row, { role }))
35
+ .toSorted((left, right) => order(left.output.meta.segment) - order(right.output.meta.segment))
36
+ .flatMap((row) => {
37
+ try {
38
+ return [readFileSync(outputPath(deps.paths, projectId, row.output.path), "utf8")];
39
+ }
40
+ catch {
41
+ // A narration file removed by hand has nothing to count.
42
+ return [];
43
+ }
44
+ });
45
+ return parts.length === 0 ? null : parts.join("\n\n");
46
+ }
47
+ function timingWords(deps, projectId, view) {
48
+ const timing = readyOutput(view, { workKey: "subtitles:timing", role: "subtitle_words" });
49
+ if (timing === undefined)
50
+ return null;
51
+ try {
52
+ const parsed = wordsSchema.parse(JSON.parse(readFileSync(outputPath(deps.paths, projectId, timing.output.path), "utf8")));
53
+ return { words: parsed.words, listened: parsed.listened };
54
+ }
55
+ catch {
56
+ // Timing that is missing or unreadable means there is nothing to say about how it was heard.
57
+ return null;
58
+ }
59
+ }
60
+ export function pronunciationCheckRoutes(deps) {
61
+ const viewOf = (projectId) => {
62
+ const revisionId = currentRevisionId(deps.db, projectId);
63
+ return revisionId === undefined ? undefined : getRevisionView(deps, projectId, revisionId);
64
+ };
65
+ const missing = "This project no longer exists. Go back to Projects to pick another.";
66
+ return (new Hono()
67
+ // Before the narration is paid for: the names this article says that nothing pronounces,
68
+ // so the voice will invent them. Read from the narration text where the run has prepared
69
+ // it, else from the article itself.
70
+ .get("/:projectId/pronunciations/gaps", zValidator("param", projectIdParam, onInvalid), (c) => {
71
+ const { projectId } = c.req.valid("param");
72
+ if (projectTitle(deps.db, projectId) === undefined)
73
+ return problem(c, { status: 404, detail: missing });
74
+ c.header("Cache-Control", "no-store");
75
+ const view = viewOf(projectId);
76
+ if (view === undefined)
77
+ return c.json({
78
+ revisionId: null,
79
+ ready: false,
80
+ narrated: false,
81
+ ahead: null,
82
+ notice: null,
83
+ });
84
+ const config = view.revision.config;
85
+ const written = narrationText(deps, projectId, view, "narration_txt");
86
+ const text = written ??
87
+ (view.articleMarkdown === null ? null : splitEndMatter(view.articleMarkdown).body);
88
+ if (text === null || text.trim() === "")
89
+ return c.json({
90
+ revisionId: view.revision.id,
91
+ ready: false,
92
+ narrated: false,
93
+ ahead: null,
94
+ notice: null,
95
+ });
96
+ const ahead = namesAhead(text, sourcesOf(config, view), readsPossessives(config));
97
+ return c.json({
98
+ revisionId: view.revision.id,
99
+ ready: true,
100
+ narrated: narrationText(deps, projectId, view, "tts_script") !== null,
101
+ ahead,
102
+ notice: namesAheadNotice(ahead),
103
+ });
104
+ })
105
+ // After the render, before the upload: what reached the voice as written, counted from the
106
+ // voice script itself, and how the finished narration was heard saying each name, from the
107
+ // confidence the subtitle timing already carries.
108
+ .get("/:projectId/pronunciations/heard", zValidator("param", projectIdParam, onInvalid), (c) => {
109
+ const { projectId } = c.req.valid("param");
110
+ if (projectTitle(deps.db, projectId) === undefined)
111
+ return problem(c, { status: 404, detail: missing });
112
+ c.header("Cache-Control", "no-store");
113
+ const view = viewOf(projectId);
114
+ if (view === undefined)
115
+ return c.json({
116
+ revisionId: null,
117
+ narrated: null,
118
+ heard: null,
119
+ listened: false,
120
+ notice: null,
121
+ });
122
+ const sources = sourcesOf(view.revision.config, view);
123
+ const script = narrationText(deps, projectId, view, "tts_script");
124
+ const written = narrationText(deps, projectId, view, "narration_txt");
125
+ const timing = timingWords(deps, projectId, view);
126
+ const narrated = script === null ? null : namesNarrated(script, sources);
127
+ const heard = timing === null
128
+ ? null
129
+ : heardNames(timing.words, sources, written ?? script ?? undefined);
130
+ return c.json({
131
+ revisionId: view.revision.id,
132
+ narrated,
133
+ heard,
134
+ listened: timing?.listened ?? false,
135
+ notice: narratedNotice(narrated, heard),
136
+ });
137
+ }));
138
+ }
@@ -3,7 +3,10 @@ import { Hono } from "hono";
3
3
  import { z } from "zod";
4
4
  import { aliasCountMax } from "../../kernel/ports/narration-aliases.js";
5
5
  import { listNarrationAliases, saveNarrationAliases, } from "../../slices/narration/aliases-library.js";
6
+ import { libraryPronunciationsMax } from "../../slices/narration/pronunciation.js";
7
+ import { readLibraryPronunciations, saveLibraryPronunciations, } from "../../slices/narration/pronunciations-library.js";
6
8
  import { collectSharedGlossary } from "../../slices/narration/shared-glossary.js";
9
+ import { actorOf } from "./actor.js";
7
10
  import { onInvalid, problem } from "./problem.js";
8
11
  // The pronunciations of every project's glossary, merged, for Edit project to copy into a
9
12
  // project that shares them. `except` leaves out the project being edited, whose own glossary
@@ -19,6 +22,29 @@ export function pronunciationRoutes(deps) {
19
22
  .regex(/^[0-9A-Za-z_-]*$/)
20
23
  .optional(),
21
24
  }), onInvalid), (c) => c.json(collectSharedGlossary(deps, c.req.valid("query").except)))
25
+ // Name pronunciations, the other half of the same page: one glossary of your own, whose
26
+ // names come before the glossary each article writes. Saved whole, like the aliases; the
27
+ // reply says what narration makes of it.
28
+ .get("/library", (c) => c.json(readLibraryPronunciations(deps.db)))
29
+ // `version` is the list the save was made from, as the read above gives it. Once a list
30
+ // exists a save without it, or from an older one, is refused with the list as it stands,
31
+ // so a second window or an agent can't quietly replace what somebody wrote.
32
+ .put("/library", zValidator("json", z.object({
33
+ text: z.string().max(libraryPronunciationsMax),
34
+ version: z.string().max(64).optional(),
35
+ }), onInvalid), (c) => {
36
+ const body = c.req.valid("json");
37
+ const result = saveLibraryPronunciations(deps, body.text, body.version, actorOf(c));
38
+ if (result.ok)
39
+ return c.json(result.list);
40
+ if (result.reason === "conflict")
41
+ return problem(c, {
42
+ status: 409,
43
+ detail: result.detail,
44
+ extensions: { current: result.current },
45
+ });
46
+ return problem(c, { status: 400, detail: result.detail });
47
+ })
22
48
  .get("/aliases", (c) => c.json({ aliases: listNarrationAliases(deps.db) }))
23
49
  // The editor saves the whole ordered list at once; each row is checked in the slice, so
24
50
  // the editor gets "Alias 3: …" sentences rather than schema paths.
@@ -148,6 +148,30 @@ export const libraryTools = [
148
148
  title: "Read the shared glossary",
149
149
  description: "The pronunciation glossary shared by every project.",
150
150
  },
151
+ {
152
+ name: "get_library_pronunciations",
153
+ route: "GET /api/pronunciations/library",
154
+ title: "Read your pronunciation list",
155
+ description: "Settings → Voices → Name pronunciations: your own glossary of Term: /ipa/ lines, whose names come before each article's glossary, with the rows narration skips and the version a save must carry.",
156
+ },
157
+ {
158
+ name: "set_library_pronunciations",
159
+ route: "PUT /api/pronunciations/library",
160
+ title: "Save your pronunciation list",
161
+ description: "Saves the whole list, like Save pronunciations. Body: {text} of Term: /ipa/ lines (or a Term | IPA table), and {version} as get_library_pronunciations gave it, so this save replaces nothing written meanwhile. A refusal (409) names what the list is now and hands it back: read its version, fold your change into that text, and send it again. New projects use the list; a project already started keeps its copy until Edit settings → Narration copies it again.",
162
+ },
163
+ {
164
+ name: "check_pronunciations",
165
+ route: "GET /api/projects/:projectId/pronunciations/gaps",
166
+ title: "Check a project's names before the narration is paid for",
167
+ description: "The names this article says that nothing pronounces — not its own Pronunciation Glossary, not your Name pronunciations list, not the pooled copy of the other projects' glossaries — so the voice will invent them. Each name with how often it is said plain and as a possessive, proper names first, and `notice` as the one line the project page shows. Read it while the narration is still unmade: adding the names with set_library_pronunciations settles them for every later video too. It changes nothing and costs nothing.",
168
+ },
169
+ {
170
+ name: "check_narration_names",
171
+ route: "GET /api/projects/:projectId/pronunciations/heard",
172
+ title: "Check how the finished narration said a project's names",
173
+ description: "Two answers about a rendered project, from files it already has. `narrated` counts the names that reached the voice as written rather than as IPA, from the voice script itself, so it is what the voice was really sent. `heard` reads the subtitle timing: each name's mean confidence and the spread across its mentions, `uneven` for the names whose mentions disagree with each other, and `saidAs` for the letters the timing model heard over a mention where they were not the name itself (spelling that is only spelling is folded away first, so CREEG for Kreeg and BELL for Bel are never reported), `ipa` for the pronunciation the sources asked for, and `different` for the names most of whose mentions came out as a sound the name does not spell, which is the only part of the check that says how a name was said rather than how clearly. A rare name scores low even when it is said perfectly, so a low score alone never flags. `listened` is false for captions timed before Slopify recorded what it heard: there an empty `saidAs` means nobody listened, not that the name came out right, and making the captions again fills it in for nothing but time. Nothing is listened to again, re-aligned or changed by this tool.",
174
+ },
151
175
  {
152
176
  name: "get_narration_aliases",
153
177
  route: "GET /api/pronunciations/aliases",
@@ -0,0 +1,28 @@
1
+ # Slopify 4.2.0
2
+
3
+ Released 8 November 2026.
4
+
5
+ This release is about names: deciding once how each one is said, and finding out — before you pay for a narration, and before you upload — whether the video said them right.
6
+
7
+ ## One list decides how a name is said
8
+
9
+ - **Settings → Voices → Name pronunciations** takes a Pronunciation Glossary of your own, one `Term: /IPA/` per line, and its names come before the glossary each article writes. A name listed there sounds the same in every video, however that video's article spelled it out, so `Gygax` never drifts to `Gigax` between episodes. The page says how many names the list pronounces and names any line narration has to skip, by number and reason.
10
+ - **Each project keeps the copy it started with,** like the narration aliases, so editing the list never changes a project behind its back. **Edit settings → Narration → Use pronunciations → Copy the list again** brings the new list in, and only the narration where a changed name appears is made again.
11
+ - **The order, where two disagree:** an alias (it changes the words), then your Name pronunciations, then the article's own Pronunciation Glossary, then other projects' pronunciations while they are shared.
12
+ - **"Use the article's pronunciation glossary" is now "Use pronunciations",** because the one switch covers both your list and the article's glossary. Inworld TTS-2 and TTS-2 Flash read IPA, as before; for any other voice an alias is still the way to fix a word.
13
+ - **One save never replaces another.** Each save carries the version of the list it was made from. When a second window or an agent saved meanwhile, the save is refused with the list as it stands: the page names what changed, keeps what you typed, and offers **Save mine anyway** or **Load theirs instead**.
14
+
15
+ ## Does this video say the names right?
16
+
17
+ Two checks on the project page, over files it already has. Neither blocks the run or the upload, neither costs anything, and neither changes a thing.
18
+
19
+ - **Before the narration is paid for,** the **Narration** section says in one line which names this article uses that nothing pronounces — not its own Pronunciation Glossary, not your Name pronunciations list, not the pooled copy of the other projects' glossaries — and **Every mention** opens the table: each name, how often it is said as written and how often as a possessive, proper names first. It is the cheapest moment to settle a name, because adding it to the list costs nothing and the voice has not been paid yet.
20
+ - **It catches the names a glossary looks like it covers and doesn't.** A two-word term is never matched by one of its words said alone, so an article that lists `Belwar Dissengulp` and then says `Belwar` on its own is named. So are possessives and hyphenated names in a project narrated under the old pronunciation rules, which never wrapped either: a project is judged by the rules it was voiced under, not by today's.
21
+ - **After the render, before the upload,** the same line sits on the **YouTube** section and says two things. What the voice was actually sent, counted from the voice script itself — a name with no IPA in front of it there was guessed, whatever the glossary says. And how the finished narration was heard saying each name, read from the per-word confidence the subtitle timing already wrote when the captions were made.
22
+ - **The spread is the signal, not a low score.** A rare name is heard poorly even when it is said perfectly, so only a name whose own mentions disagree with each other is flagged: eight mentions or more, and a spread of 0.30 or more between its tenth and ninetieth percentile. Nothing is listened to again and nothing is re-aligned.
23
+ - **Ordinary English words are left out of both.** A one-word name of four letters or more, written with a capital, that the same text also writes in lower case is a word there and not a name — so a pooled `Hall` stays quiet in a video that also says "hall", while a god called `Torm` does not.
24
+
25
+ ## For agents
26
+
27
+ - `get_library_pronunciations` and `set_library_pronunciations` read and write the list, the save carrying `{text, version}`; a 409 names what the list is now and hands it back to save from.
28
+ - `check_pronunciations` answers before the narration, `check_narration_names` after the render.
@@ -0,0 +1,21 @@
1
+ # Slopify 4.3.0
2
+
3
+ Released 9 November 2026.
4
+
5
+ 4.2.0 could tell you how *clearly* the voice said a name. It could not tell you *which* name it said. This release closes that, using something the computer was already working out and throwing away.
6
+
7
+ ## What the voice actually said
8
+
9
+ - **The YouTube section now lists the names that came out as another sound,** with the letters the timing model heard, how many of its mentions came out that way, and the IPA you asked for beside them — `Lolth · 28 said · “loth” · 25 of 28 · you asked for /lɒlθ/`. This is the one thing confidence can never show: a name mispronounced the same way in every mention scores as well as one said right, so the spread in 4.2.0 saw nothing.
10
+ - **It costs nothing and listens to nothing twice.** Timing the captions already decodes every window to check the audio against the transcript at all; that same decode over one word's own frames is the letters the model heard there. Slopify now keeps them wherever they are not the word itself. On a two-hour narration about five words in a hundred carry them.
11
+ - **Spelling that is only spelling is folded away first.** The model writes what it hears and has no idea how a name is spelled: on a real two-hour narration it wrote “creeg” for Kreeg, “bell” for Bel, “tier” for Tyr, “drist” for Drizzt and “fandelver” for Phandelver — every one of them the name said correctly. All of those are folded out and none is reported, while a name heard as a sound it does not spell is kept.
12
+ - **One mention never decides.** A name is listed only where one other sound covers six in ten of at least three mentions. On that same video it names three of the hundred and fifty names the video tracks, and leaves the rest alone — `Elturel` among them, heard as “elterel” thirty-three times of a hundred and forty-five, which is real and is not most of them.
13
+ - **The letters are a second model listening, not a verdict,** and the page says so. A name whose spelling is nothing like its sound can be listed although the voice read it correctly; read the letters against the IPA, and if what came out is wrong, change that name's IPA in **Name pronunciations** and narrate again.
14
+
15
+ ## An older video says so instead of passing
16
+
17
+ Captions timed before this release carry none of this, and a name with nothing against it there was never listened to — which is not the same as a name that came out right. The check says that plainly rather than calling the video clean. Making its captions again fills it in and costs nothing but time.
18
+
19
+ ## For agents
20
+
21
+ - `check_narration_names` gains `saidAs` and `ipa` on every name, `different` for the names most of whose mentions came out as another sound, and `listened` for whether the timing was ever listened to at all.
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "id": "4.3.0",
4
+ "title": "Slopify 4.3.0",
5
+ "version": "4.3.0",
6
+ "date": "2026-11-09"
7
+ },
8
+ {
9
+ "id": "4.2.0",
10
+ "title": "Slopify 4.2.0",
11
+ "version": "4.2.0",
12
+ "date": "2026-11-08"
13
+ },
2
14
  {
3
15
  "id": "4.1.0",
4
16
  "title": "Slopify 4.1.0",
@@ -7,6 +7,7 @@ import { documentSettingsSchema } from "../document/theme-schema.js";
7
7
  import { keywordFactsSchema } from "../keywords/schema.js";
8
8
  import { narrationAliasesSchema } from "../narration/aliases-schema.js";
9
9
  import { chunkModes } from "../narration/chunk.js";
10
+ import { libraryPronunciationsMax } from "../narration/pronunciation.js";
10
11
  import { reviewModes, reviewStages } from "../reviews/model.js";
11
12
  import { socialSettingsSchema } from "../social/schema.js";
12
13
  import { subtitleConfigSchema } from "../subtitles/model.js";
@@ -72,6 +73,7 @@ export const runDraftSchema = z.object({
72
73
  })
73
74
  .optional(),
74
75
  narrationAliases: narrationAliasesSchema.optional(),
76
+ libraryPronunciations: z.string().max(libraryPronunciationsMax).optional(),
75
77
  pronunciationRules: z.union([z.literal(1), z.literal(2)]).optional(),
76
78
  sharedGlossary: z
77
79
  .array(z.object({
@@ -8,6 +8,7 @@ import { keywordFactsFor } from "../keywords/facts.js";
8
8
  import { usesFacts } from "../keywords/rules.js";
9
9
  import { listNarrationAliases } from "../narration/aliases-library.js";
10
10
  import { currentPronunciationRules } from "../narration/pronunciation.js";
11
+ import { readLibraryPronunciations } from "../narration/pronunciations-library.js";
11
12
  import { collectSharedGlossary } from "../narration/shared-glossary.js";
12
13
  import { admitInitialRevision } from "../rebuild/runtime-admission.js";
13
14
  import { adoptBaseline } from "../revisions/adopt.js";
@@ -117,6 +118,11 @@ projectId) {
117
118
  const aliases = draft.sources.audio === "generate" && draft.audio?.useNarrationAliases === true
118
119
  ? listNarrationAliases(deps.db)
119
120
  : [];
121
+ // Settings → Voices → Pronunciation's own glossary as it is now: the names this channel
122
+ // always says the same way, ahead of whatever this article's glossary spells out.
123
+ const pronunciations = usesPronunciationGlossary(draft)
124
+ ? readLibraryPronunciations(deps.db).text
125
+ : "";
120
126
  // The title may name keywords too ("History: {{Topic}}"), filled like a prompt's, so a
121
127
  // template or Play keeps the pattern and each project gets its own title.
122
128
  const title = render(draft.title, draft.values).trim() || draft.title;
@@ -142,6 +148,7 @@ projectId) {
142
148
  rendered,
143
149
  ...(shared.length === 0 ? {} : { sharedGlossary: shared }),
144
150
  ...(aliases.length === 0 ? {} : { narrationAliases: aliases }),
151
+ ...(pronunciations === "" ? {} : { libraryPronunciations: pronunciations }),
145
152
  pronunciationRules: currentPronunciationRules,
146
153
  ...(earlier === undefined ? {} : { earlierEpisodes: earlier }),
147
154
  ...(facts === undefined ? {} : { keywordFacts: facts }),
@@ -159,6 +159,7 @@ export const assistantFreeTools = [
159
159
  "create_document_theme",
160
160
  "update_document_theme",
161
161
  "set_narration_aliases",
162
+ "set_library_pronunciations",
162
163
  "update_settings",
163
164
  "set_default_channel_links",
164
165
  "add_voice",