@gentbajko/slopify 4.1.0 → 4.2.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.
@@ -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,133 @@
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
+ return wordsSchema.parse(JSON.parse(readFileSync(outputPath(deps.paths, projectId, timing.output.path), "utf8"))).words;
53
+ }
54
+ catch {
55
+ // Timing that is missing or unreadable means there is nothing to say about how it was heard.
56
+ return null;
57
+ }
58
+ }
59
+ export function pronunciationCheckRoutes(deps) {
60
+ const viewOf = (projectId) => {
61
+ const revisionId = currentRevisionId(deps.db, projectId);
62
+ return revisionId === undefined ? undefined : getRevisionView(deps, projectId, revisionId);
63
+ };
64
+ const missing = "This project no longer exists. Go back to Projects to pick another.";
65
+ return (new Hono()
66
+ // Before the narration is paid for: the names this article says that nothing pronounces,
67
+ // so the voice will invent them. Read from the narration text where the run has prepared
68
+ // it, else from the article itself.
69
+ .get("/:projectId/pronunciations/gaps", zValidator("param", projectIdParam, onInvalid), (c) => {
70
+ const { projectId } = c.req.valid("param");
71
+ if (projectTitle(deps.db, projectId) === undefined)
72
+ return problem(c, { status: 404, detail: missing });
73
+ c.header("Cache-Control", "no-store");
74
+ const view = viewOf(projectId);
75
+ if (view === undefined)
76
+ return c.json({
77
+ revisionId: null,
78
+ ready: false,
79
+ narrated: false,
80
+ ahead: null,
81
+ notice: null,
82
+ });
83
+ const config = view.revision.config;
84
+ const written = narrationText(deps, projectId, view, "narration_txt");
85
+ const text = written ??
86
+ (view.articleMarkdown === null ? null : splitEndMatter(view.articleMarkdown).body);
87
+ if (text === null || text.trim() === "")
88
+ return c.json({
89
+ revisionId: view.revision.id,
90
+ ready: false,
91
+ narrated: false,
92
+ ahead: null,
93
+ notice: null,
94
+ });
95
+ const ahead = namesAhead(text, sourcesOf(config, view), readsPossessives(config));
96
+ return c.json({
97
+ revisionId: view.revision.id,
98
+ ready: true,
99
+ narrated: narrationText(deps, projectId, view, "tts_script") !== null,
100
+ ahead,
101
+ notice: namesAheadNotice(ahead),
102
+ });
103
+ })
104
+ // After the render, before the upload: what reached the voice as written, counted from the
105
+ // voice script itself, and how the finished narration was heard saying each name, from the
106
+ // confidence the subtitle timing already carries.
107
+ .get("/:projectId/pronunciations/heard", zValidator("param", projectIdParam, onInvalid), (c) => {
108
+ const { projectId } = c.req.valid("param");
109
+ if (projectTitle(deps.db, projectId) === undefined)
110
+ return problem(c, { status: 404, detail: missing });
111
+ c.header("Cache-Control", "no-store");
112
+ const view = viewOf(projectId);
113
+ if (view === undefined)
114
+ return c.json({
115
+ revisionId: null,
116
+ narrated: null,
117
+ heard: null,
118
+ notice: null,
119
+ });
120
+ const sources = sourcesOf(view.revision.config, view);
121
+ const script = narrationText(deps, projectId, view, "tts_script");
122
+ const written = narrationText(deps, projectId, view, "narration_txt");
123
+ const words = timingWords(deps, projectId, view);
124
+ const narrated = script === null ? null : namesNarrated(script, sources);
125
+ const heard = words === null ? null : heardNames(words, sources, written ?? script ?? undefined);
126
+ return c.json({
127
+ revisionId: view.revision.id,
128
+ narrated,
129
+ heard,
130
+ notice: narratedNotice(narrated, heard),
131
+ });
132
+ }));
133
+ }
@@ -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 per-word confidence of the subtitle timing: each name's mean and the spread across its mentions, and `uneven` for the names whose mentions disagree with each other, which is the sign a name was not said the same way twice. A rare name scores low even when it is said perfectly, so a low score alone never flags. Nothing is listened to again, re-aligned or changed.",
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.
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "id": "4.2.0",
4
+ "title": "Slopify 4.2.0",
5
+ "version": "4.2.0",
6
+ "date": "2026-11-08"
7
+ },
2
8
  {
3
9
  "id": "4.1.0",
4
10
  "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",
@@ -0,0 +1,226 @@
1
+ import { parsePronunciationGlossary, pronunciationMatches, pronunciationSpans, } from "./pronunciation.js";
2
+ import { libraryGlossary } from "./pronunciations-library.js";
3
+ import { withSharedGlossary } from "./shared-glossary.js";
4
+ // Measured over the 30 finished videos in this install: "Do'Urden" spreads 0.36 in both videos
5
+ // that say it and is heard several ways, while "Gygax" — a rare name the aligner always scores
6
+ // low — never spreads past 0.29 in the 26 videos that say it, so a low score on its own never
7
+ // flags. Eight mentions is the fewest from which the 10th and 90th percentiles mean anything.
8
+ export const heardMinMentions = 8;
9
+ export const heardSpreadFlag = 0.3;
10
+ // The entries narration pronounces with, assembled the way `recipe-text.ts` assembles them.
11
+ // Parsed twice: once under the rules the project narrates with, which is what actually guides
12
+ // the voice, and once under the current rules, which is the fullest list of names the sources
13
+ // name at all — a hyphenated term rules 1 had to skip is still a name this video says.
14
+ export function pronunciationEntries(sources) {
15
+ const under = (rules) => {
16
+ const own = parsePronunciationGlossary(sources.glossary, sources.language, rules);
17
+ const list = libraryGlossary(sources.list, sources.language, rules);
18
+ return withSharedGlossary(list.ok ? list.entries : [], withSharedGlossary(own.ok ? own.entries : [], sources.shared));
19
+ };
20
+ return { guiding: under(sources.rules), listed: under(2) };
21
+ }
22
+ // Delivery tags are the preparation's instructions to the voice, not words it reads, so a name
23
+ // inside one is not a mention. Blanked, not removed, so every offset still points at the text.
24
+ function withoutTags(source) {
25
+ return source.replace(/\[[^\]\n]*\]/gu, (tag) => " ".repeat(tag.length));
26
+ }
27
+ // Everything between a pair of slashes already reached the voice as IPA. Whitespace inside is
28
+ // never IPA, which keeps an "and/or … his/her" in ordinary prose from reading as a span.
29
+ function ipaRanges(source) {
30
+ return Array.from(source.matchAll(/\/[^/\s]+\//gu), (match) => [match.index, match.index + match[0].length]);
31
+ }
32
+ // The names to look for: every term the sources name, and — because a two-word term never
33
+ // matches one of its words said alone ("Belwar Dissengulp" listed, "Belwar" spoken) — each
34
+ // capitalised word of a longer term as a name of its own.
35
+ //
36
+ // A one-word name of four letters or more that the text also writes in lower case is an
37
+ // ordinary English word here, not a name: the pooled copy of the other projects' glossaries
38
+ // carries "Hall" (of "Mithral Hall"), "Quick" and "Claw", and an article that also says "halls"
39
+ // or "quickly" is not mispronouncing them. An ending of one or two letters counts as the same
40
+ // word, which is why "quickly" speaks for "Quick"; more than that does not, so "torment" leaves
41
+ // the god Torm alone. A term of several words, or a short one like "Bel", is never read this way.
42
+ function lowercaseWords(source) {
43
+ const words = new Set();
44
+ for (const match of source.matchAll(/(?<![\p{L}\p{N}'’-])\p{Ll}[\p{L}'’-]*/gu)) {
45
+ const word = match[0];
46
+ words.add(word);
47
+ if (word.length > 1)
48
+ words.add(word.slice(0, -1));
49
+ if (word.length > 2)
50
+ words.add(word.slice(0, -2));
51
+ }
52
+ return words;
53
+ }
54
+ function ordinaryWord(term, lowercased) {
55
+ return (term.length >= 4 &&
56
+ !term.includes(" ") &&
57
+ // Only a name written with a capital can be mistaken for one: a term the glossary itself
58
+ // writes in lower case ("drow", "lich", "tiefling") is a common word by its own spelling,
59
+ // and is told apart by `proper` rather than left out.
60
+ /^\p{Lu}/u.test(term) &&
61
+ lowercased.has(term.toLowerCase()));
62
+ }
63
+ function nameCandidates(entries, source) {
64
+ const lowercased = lowercaseWords(source);
65
+ const candidates = new Map();
66
+ for (const entry of entries) {
67
+ if (ordinaryWord(entry.term, lowercased))
68
+ continue;
69
+ candidates.set(entry.term.toLowerCase(), entry);
70
+ }
71
+ for (const entry of entries) {
72
+ const words = entry.term.split(" ");
73
+ if (words.length < 2)
74
+ continue;
75
+ for (const [index, word] of words.entries()) {
76
+ const key = word.toLowerCase();
77
+ if (word.length < 3 || !/^\p{Lu}/u.test(word))
78
+ continue;
79
+ if (candidates.has(key) || ordinaryWord(word, lowercased))
80
+ continue;
81
+ candidates.set(key, { term: word, ipa: [entry.ipa[index] ?? ""] });
82
+ }
83
+ }
84
+ return [...candidates.values()];
85
+ }
86
+ function countUnguided(source, entries, guided) {
87
+ const text = withoutTags(source);
88
+ const rows = new Map();
89
+ // Possessives are always matched here, whatever the project's rules: a possessive the rules
90
+ // never wrapped is exactly the mention that reached the voice as written.
91
+ for (const match of pronunciationMatches(text, nameCandidates(entries, text), true)) {
92
+ if (guided.some(([from, to]) => match.start < to && match.end > from))
93
+ continue;
94
+ // Named by what the text says, not by the glossary's spelling of the term.
95
+ const said = text.slice(match.start, match.end);
96
+ const name = said.replace(/['’]s$/u, "");
97
+ const row = rows.get(name) ?? { plain: 0, possessive: 0, proper: /^\p{Lu}/u.test(name) };
98
+ if (match.possessive === undefined)
99
+ row.plain++;
100
+ else
101
+ row.possessive++;
102
+ rows.set(name, row);
103
+ }
104
+ const total = (row) => row.plain + row.possessive;
105
+ const names = [...rows.entries()]
106
+ .map(([name, row]) => ({ name, ...row }))
107
+ .sort((a, b) => Number(b.proper) - Number(a.proper) ||
108
+ total(b) - total(a) ||
109
+ a.name.localeCompare(b.name, "en"));
110
+ return {
111
+ names,
112
+ mentions: names.reduce((sum, row) => sum + total(row), 0),
113
+ proper: names.filter((row) => row.proper).reduce((sum, row) => sum + total(row), 0),
114
+ };
115
+ }
116
+ // Before the narration runs: the names this text says that the voice will have to invent. What
117
+ // narration will wrap is `pronunciationSpans` itself, under the project's own rules, so a
118
+ // project that narrates without possessives is told about its possessives and a newer one is not.
119
+ export function namesAhead(text, sources, possessives) {
120
+ const { guiding, listed } = pronunciationEntries(sources);
121
+ const plain = withoutTags(text);
122
+ const guided = pronunciationSpans(plain, guiding, possessives).map((span) => [span.start, span.end]);
123
+ return countUnguided(plain, listed, guided);
124
+ }
125
+ // After the render: the same names, counted from the voice script, which is exactly the text the
126
+ // voice was sent. A name with no IPA in front of it there was guessed, whatever the glossary says.
127
+ export function namesNarrated(script, sources) {
128
+ const { listed } = pronunciationEntries(sources);
129
+ return countUnguided(script, listed, ipaRanges(withoutTags(script)));
130
+ }
131
+ // A word as the aligner read it: "Do'Urden," is the name, "Drizzt's" is the name too.
132
+ function spokenName(text) {
133
+ return text
134
+ .replace(/^[^\p{L}\p{N}]+/u, "")
135
+ .replace(/[^\p{L}\p{N}'’-]+$/u, "")
136
+ .replace(/['’]s$/u, "");
137
+ }
138
+ function percentile(sorted, fraction) {
139
+ const at = Math.round(fraction * (sorted.length - 1));
140
+ return sorted[Math.min(sorted.length - 1, Math.max(0, at))] ?? 0;
141
+ }
142
+ // How the finished narration was heard saying each name, from the confidence the subtitle timing
143
+ // already carries. Nothing here is listened to again and nothing is re-aligned: the numbers are
144
+ // the ones written when the captions were timed.
145
+ export function heardNames(words, sources,
146
+ // The narration as it was written: the corpus the candidates are judged against, so a pooled
147
+ // "Hall" is left alone in a video that also says "hall" (see `nameCandidates`). The words as
148
+ // the aligner wrote them down stand in when the narration itself is not at hand.
149
+ corpus) {
150
+ const { listed } = pronunciationEntries(sources);
151
+ const heardText = corpus ?? words.map((word) => word.text).join(" ");
152
+ const lowercased = lowercaseWords(heardText);
153
+ const wanted = new Map();
154
+ // A word of the timing is one word, so a term of several reaches it a part at a time, and a
155
+ // hyphenated name may arrive whole or in parts. A part that is an ordinary word of this text
156
+ // ("Hall" of "Mithral Hall", in a video that also says "hall") is left out, as it is above.
157
+ for (const entry of nameCandidates(listed, heardText))
158
+ for (const part of [entry.term, ...entry.term.split(/[\s‐‑-]+/u)])
159
+ if (part.length >= 3 && !wanted.has(part.toLowerCase()) && !ordinaryWord(part, lowercased))
160
+ wanted.set(part.toLowerCase(), part);
161
+ const scored = words.flatMap((word) => typeof word.confidence === "number" ? [word.confidence] : []);
162
+ const by = new Map();
163
+ for (const word of words) {
164
+ if (typeof word.confidence !== "number")
165
+ continue;
166
+ const name = wanted.get(spokenName(word.text).toLowerCase());
167
+ if (name === undefined)
168
+ continue;
169
+ by.set(name, [...(by.get(name) ?? []), word.confidence]);
170
+ }
171
+ const names = [...by.entries()]
172
+ .map(([name, values]) => {
173
+ const sorted = [...values].sort((a, b) => a - b);
174
+ const low = percentile(sorted, 0.1);
175
+ const high = percentile(sorted, 0.9);
176
+ return {
177
+ name,
178
+ mentions: sorted.length,
179
+ mean: sorted.reduce((sum, value) => sum + value, 0) / sorted.length,
180
+ low,
181
+ high,
182
+ spread: high - low,
183
+ uneven: sorted.length >= heardMinMentions && high - low >= heardSpreadFlag,
184
+ };
185
+ })
186
+ .sort((a, b) => b.spread - a.spread || a.name.localeCompare(b.name, "en"));
187
+ return {
188
+ names,
189
+ uneven: names.filter((row) => row.uneven),
190
+ words: scored.length,
191
+ confidence: scored.length === 0 ? 0 : scored.reduce((sum, value) => sum + value, 0) / scored.length,
192
+ };
193
+ }
194
+ const list = (names, most = 6) => {
195
+ const shown = names.slice(0, most).map((row) => row.name);
196
+ const rest = names.length - shown.length;
197
+ return rest === 0 ? shown.join(", ") : `${shown.join(", ")} and ${String(rest)} more`;
198
+ };
199
+ // The one line before the narration is paid for. Names the names, says what will happen to them,
200
+ // and says the one place that fixes them for every video at once.
201
+ export function namesAheadNotice(result) {
202
+ const proper = result.names.filter((row) => row.proper);
203
+ if (proper.length === 0)
204
+ return null;
205
+ return (`${proper.length === 1 ? "1 name" : `${String(proper.length)} names`} in this article ` +
206
+ `${proper.length === 1 ? "has" : "have"} no pronunciation anywhere, so the voice will guess ` +
207
+ `${proper.length === 1 ? "it" : "them"}: ${list(proper)}. ` +
208
+ `To decide how they are said, add them to Settings → Voices → Name pronunciations, then make ` +
209
+ `the narration.`);
210
+ }
211
+ // The one line on the upload section, after the render: what the voice guessed, and what it was
212
+ // heard saying more than one way. Null when there is nothing to say.
213
+ export function narratedNotice(narrated, heard) {
214
+ const guessed = narrated?.names.filter((row) => row.proper) ?? [];
215
+ const uneven = heard?.uneven ?? [];
216
+ if (guessed.length === 0 && uneven.length === 0)
217
+ return null;
218
+ const parts = [];
219
+ if (guessed.length > 0)
220
+ parts.push(`the voice guessed ${guessed.length === 1 ? "1 name" : `${String(guessed.length)} names`} ` +
221
+ `(${list(guessed)})`);
222
+ if (uneven.length > 0)
223
+ parts.push(`${uneven.length === 1 ? "1 name was" : `${String(uneven.length)} names were`} heard more ` +
224
+ `than one way (${list(uneven)})`);
225
+ return `Names in this narration: ${parts.join(", and ")}. Open the check to see every mention.`;
226
+ }
@@ -3,6 +3,10 @@ import remarkGfm from "remark-gfm";
3
3
  import { internalError } from "../../kernel/errors.js";
4
4
  import { createRows, editSettings } from "../../kernel/ports/settings-places.js";
5
5
  import { remembered } from "../../kernel/remembered.js";
6
+ // How long Settings → Voices → Pronunciation's own list may be, and with it the copy a project
7
+ // carries (`slices/narration/pronunciations-library.ts`). Long enough for a channel's whole cast
8
+ // of names.
9
+ export const libraryPronunciationsMax = 100_000;
6
10
  export const currentPronunciationRules = 2;
7
11
  export function pronunciationRulesOf(config) {
8
12
  return config.pronunciationRules ?? 1;