@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.
@@ -0,0 +1,318 @@
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
+ // A name counts as said differently when most of its mentions came out as the same other sound.
11
+ // Three mentions is the fewest where "every time" means anything, and six in ten keeps a name
12
+ // the voice says both ways out of it: that one belongs under `uneven`, which is about mentions
13
+ // disagreeing with each other.
14
+ export const differentMinMentions = 3;
15
+ export const differentShare = 0.6;
16
+ // Pairs of letters that spell one sound, folded so that a spelling and a sound are comparable.
17
+ // The timing model writes down what it hears, letter by letter, with no idea how the name is
18
+ // spelled: it writes CREEG for Kreeg, BELL for Bel, TIER for Tyr and DRIST for Drizzt, all of
19
+ // which are the name said correctly. Only the pairs that are the same sound in English however
20
+ // they are written are here — EA is not (heart, beat), nor CH (chair, loch) — so a fold that
21
+ // cancels is good evidence and one that does not is still only evidence.
22
+ const sameSound = [
23
+ ["PH", "F"],
24
+ ["CK", "K"],
25
+ ["QU", "KW"],
26
+ ["X", "KS"],
27
+ ["EE", "I"],
28
+ ["OO", "U"],
29
+ ["OU", "U"],
30
+ ["AI", "A"],
31
+ ["AY", "A"],
32
+ ["IE", "I"],
33
+ ["OA", "O"],
34
+ ["AU", "O"],
35
+ ["AW", "O"],
36
+ ["C", "K"],
37
+ ["Z", "S"],
38
+ ["Y", "I"],
39
+ ];
40
+ // One name or one hearing of it, reduced to roughly the sounds in it: everything that is only
41
+ // spelling is dropped, so two spellings of the same sound meet here and two sounds do not.
42
+ export function soundKey(text) {
43
+ let key = text.toUpperCase().replace(/[^\p{Lu}]+/gu, "");
44
+ for (const [pair, sound] of sameSound)
45
+ key = key.replaceAll(pair, sound);
46
+ return key
47
+ .replace(/(.)\1+/gu, "$1")
48
+ .replace(/E$/u, "")
49
+ .replace(/E?S$/u, "");
50
+ }
51
+ // The entries narration pronounces with, assembled the way `recipe-text.ts` assembles them.
52
+ // Parsed twice: once under the rules the project narrates with, which is what actually guides
53
+ // the voice, and once under the current rules, which is the fullest list of names the sources
54
+ // name at all — a hyphenated term rules 1 had to skip is still a name this video says.
55
+ export function pronunciationEntries(sources) {
56
+ const under = (rules) => {
57
+ const own = parsePronunciationGlossary(sources.glossary, sources.language, rules);
58
+ const list = libraryGlossary(sources.list, sources.language, rules);
59
+ return withSharedGlossary(list.ok ? list.entries : [], withSharedGlossary(own.ok ? own.entries : [], sources.shared));
60
+ };
61
+ return { guiding: under(sources.rules), listed: under(2) };
62
+ }
63
+ // Delivery tags are the preparation's instructions to the voice, not words it reads, so a name
64
+ // inside one is not a mention. Blanked, not removed, so every offset still points at the text.
65
+ function withoutTags(source) {
66
+ return source.replace(/\[[^\]\n]*\]/gu, (tag) => " ".repeat(tag.length));
67
+ }
68
+ // Everything between a pair of slashes already reached the voice as IPA. Whitespace inside is
69
+ // never IPA, which keeps an "and/or … his/her" in ordinary prose from reading as a span.
70
+ function ipaRanges(source) {
71
+ return Array.from(source.matchAll(/\/[^/\s]+\//gu), (match) => [match.index, match.index + match[0].length]);
72
+ }
73
+ // The names to look for: every term the sources name, and — because a two-word term never
74
+ // matches one of its words said alone ("Belwar Dissengulp" listed, "Belwar" spoken) — each
75
+ // capitalised word of a longer term as a name of its own.
76
+ //
77
+ // A one-word name of four letters or more that the text also writes in lower case is an
78
+ // ordinary English word here, not a name: the pooled copy of the other projects' glossaries
79
+ // carries "Hall" (of "Mithral Hall"), "Quick" and "Claw", and an article that also says "halls"
80
+ // or "quickly" is not mispronouncing them. An ending of one or two letters counts as the same
81
+ // word, which is why "quickly" speaks for "Quick"; more than that does not, so "torment" leaves
82
+ // the god Torm alone. A term of several words, or a short one like "Bel", is never read this way.
83
+ function lowercaseWords(source) {
84
+ const words = new Set();
85
+ for (const match of source.matchAll(/(?<![\p{L}\p{N}'’-])\p{Ll}[\p{L}'’-]*/gu)) {
86
+ const word = match[0];
87
+ words.add(word);
88
+ if (word.length > 1)
89
+ words.add(word.slice(0, -1));
90
+ if (word.length > 2)
91
+ words.add(word.slice(0, -2));
92
+ }
93
+ return words;
94
+ }
95
+ function ordinaryWord(term, lowercased) {
96
+ return (term.length >= 4 &&
97
+ !term.includes(" ") &&
98
+ // Only a name written with a capital can be mistaken for one: a term the glossary itself
99
+ // writes in lower case ("drow", "lich", "tiefling") is a common word by its own spelling,
100
+ // and is told apart by `proper` rather than left out.
101
+ /^\p{Lu}/u.test(term) &&
102
+ lowercased.has(term.toLowerCase()));
103
+ }
104
+ function nameCandidates(entries, source) {
105
+ const lowercased = lowercaseWords(source);
106
+ const candidates = new Map();
107
+ for (const entry of entries) {
108
+ if (ordinaryWord(entry.term, lowercased))
109
+ continue;
110
+ candidates.set(entry.term.toLowerCase(), entry);
111
+ }
112
+ for (const entry of entries) {
113
+ const words = entry.term.split(" ");
114
+ if (words.length < 2)
115
+ continue;
116
+ for (const [index, word] of words.entries()) {
117
+ const key = word.toLowerCase();
118
+ if (word.length < 3 || !/^\p{Lu}/u.test(word))
119
+ continue;
120
+ if (candidates.has(key) || ordinaryWord(word, lowercased))
121
+ continue;
122
+ candidates.set(key, { term: word, ipa: [entry.ipa[index] ?? ""] });
123
+ }
124
+ }
125
+ return [...candidates.values()];
126
+ }
127
+ function countUnguided(source, entries, guided) {
128
+ const text = withoutTags(source);
129
+ const rows = new Map();
130
+ // Possessives are always matched here, whatever the project's rules: a possessive the rules
131
+ // never wrapped is exactly the mention that reached the voice as written.
132
+ for (const match of pronunciationMatches(text, nameCandidates(entries, text), true)) {
133
+ if (guided.some(([from, to]) => match.start < to && match.end > from))
134
+ continue;
135
+ // Named by what the text says, not by the glossary's spelling of the term.
136
+ const said = text.slice(match.start, match.end);
137
+ const name = said.replace(/['’]s$/u, "");
138
+ const row = rows.get(name) ?? { plain: 0, possessive: 0, proper: /^\p{Lu}/u.test(name) };
139
+ if (match.possessive === undefined)
140
+ row.plain++;
141
+ else
142
+ row.possessive++;
143
+ rows.set(name, row);
144
+ }
145
+ const total = (row) => row.plain + row.possessive;
146
+ const names = [...rows.entries()]
147
+ .map(([name, row]) => ({ name, ...row }))
148
+ .sort((a, b) => Number(b.proper) - Number(a.proper) ||
149
+ total(b) - total(a) ||
150
+ a.name.localeCompare(b.name, "en"));
151
+ return {
152
+ names,
153
+ mentions: names.reduce((sum, row) => sum + total(row), 0),
154
+ proper: names.filter((row) => row.proper).reduce((sum, row) => sum + total(row), 0),
155
+ };
156
+ }
157
+ // Before the narration runs: the names this text says that the voice will have to invent. What
158
+ // narration will wrap is `pronunciationSpans` itself, under the project's own rules, so a
159
+ // project that narrates without possessives is told about its possessives and a newer one is not.
160
+ export function namesAhead(text, sources, possessives) {
161
+ const { guiding, listed } = pronunciationEntries(sources);
162
+ const plain = withoutTags(text);
163
+ const guided = pronunciationSpans(plain, guiding, possessives).map((span) => [span.start, span.end]);
164
+ return countUnguided(plain, listed, guided);
165
+ }
166
+ // After the render: the same names, counted from the voice script, which is exactly the text the
167
+ // voice was sent. A name with no IPA in front of it there was guessed, whatever the glossary says.
168
+ export function namesNarrated(script, sources) {
169
+ const { listed } = pronunciationEntries(sources);
170
+ return countUnguided(script, listed, ipaRanges(withoutTags(script)));
171
+ }
172
+ // A word as the aligner read it: "Do'Urden," is the name, "Drizzt's" is the name too.
173
+ function spokenName(text) {
174
+ return text
175
+ .replace(/^[^\p{L}\p{N}]+/u, "")
176
+ .replace(/[^\p{L}\p{N}'’-]+$/u, "")
177
+ .replace(/['’]s$/u, "");
178
+ }
179
+ function percentile(sorted, fraction) {
180
+ const at = Math.round(fraction * (sorted.length - 1));
181
+ return sorted[Math.min(sorted.length - 1, Math.max(0, at))] ?? 0;
182
+ }
183
+ // How the finished narration was heard saying each name, from the confidence the subtitle timing
184
+ // already carries. Nothing here is listened to again and nothing is re-aligned: the numbers are
185
+ // the ones written when the captions were timed.
186
+ export function heardNames(words, sources,
187
+ // The narration as it was written: the corpus the candidates are judged against, so a pooled
188
+ // "Hall" is left alone in a video that also says "hall" (see `nameCandidates`). The words as
189
+ // the aligner wrote them down stand in when the narration itself is not at hand.
190
+ corpus) {
191
+ const { listed } = pronunciationEntries(sources);
192
+ const heardText = corpus ?? words.map((word) => word.text).join(" ");
193
+ const lowercased = lowercaseWords(heardText);
194
+ const wanted = new Map();
195
+ // A word of the timing is one word, so a term of several reaches it a part at a time, and a
196
+ // hyphenated name may arrive whole or in parts. A part that is an ordinary word of this text
197
+ // ("Hall" of "Mithral Hall", in a video that also says "hall") is left out, as it is above.
198
+ // A term stands as it is written, in lower case too ("tiefling"), but a part of a longer
199
+ // term has to look like a name on its own: the "and" of a term that has one is a word.
200
+ for (const entry of nameCandidates(listed, heardText))
201
+ for (const [at, part] of [entry.term, ...entry.term.split(/[\s‐‑-]+/u)].entries())
202
+ if (part.length >= 3 &&
203
+ (at === 0 || /^\p{Lu}/u.test(part)) &&
204
+ !wanted.has(part.toLowerCase()) &&
205
+ !ordinaryWord(part, lowercased))
206
+ // The IPA belongs to the whole term, so only the term itself carries it: a part said
207
+ // alone is exactly the mention nothing pronounced.
208
+ wanted.set(part.toLowerCase(), { name: part, ipa: part === entry.term ? entry.ipa : [] });
209
+ const scored = words.flatMap((word) => typeof word.confidence === "number" ? [word.confidence] : []);
210
+ const by = new Map();
211
+ for (const word of words) {
212
+ if (typeof word.confidence !== "number")
213
+ continue;
214
+ const found = wanted.get(spokenName(word.text).toLowerCase());
215
+ if (found === undefined)
216
+ continue;
217
+ const row = by.get(found.name) ?? {
218
+ ipa: found.ipa,
219
+ scores: [],
220
+ said: new Map(),
221
+ };
222
+ row.scores.push(word.confidence);
223
+ // Letters only, so one mention's punctuation never splits a spelling in two.
224
+ const letters = word.heard?.toUpperCase().replace(/[^\p{L}' ]+/gu, "");
225
+ if (letters !== undefined && letters !== "")
226
+ row.said.set(letters, (row.said.get(letters) ?? 0) + 1);
227
+ by.set(found.name, row);
228
+ }
229
+ const names = [...by.entries()]
230
+ .map(([name, row]) => {
231
+ const sorted = [...row.scores].sort((a, b) => a - b);
232
+ const low = percentile(sorted, 0.1);
233
+ const high = percentile(sorted, 0.9);
234
+ // Only the hearings that are another sound: a decode that spells the name differently
235
+ // (CREEG for Kreeg) is the name, and saying so would be noise, not a finding.
236
+ const saidAs = [...row.said.entries()]
237
+ .filter(([letters]) => soundKey(letters) !== soundKey(name))
238
+ .map(([letters, mentions]) => ({ letters, mentions }))
239
+ .sort((a, b) => b.mentions - a.mentions || a.letters.localeCompare(b.letters, "en"));
240
+ // The mentions that came out as one sound, whichever ways they were spelled, against the
241
+ // name's own: CREEG and KREEG are one hearing of Kreeg, and that hearing is the name.
242
+ const key = soundKey(name);
243
+ const sounds = new Map();
244
+ for (const [letters, mentions] of row.said)
245
+ if (soundKey(letters) !== key)
246
+ sounds.set(soundKey(letters), (sounds.get(soundKey(letters)) ?? 0) + mentions);
247
+ const elsewhere = [...sounds.values()].sort((a, b) => b - a)[0] ?? 0;
248
+ return {
249
+ name,
250
+ mentions: sorted.length,
251
+ mean: sorted.reduce((sum, value) => sum + value, 0) / sorted.length,
252
+ low,
253
+ high,
254
+ spread: high - low,
255
+ uneven: sorted.length >= heardMinMentions && high - low >= heardSpreadFlag,
256
+ saidAs,
257
+ ipa: row.ipa,
258
+ different: sorted.length >= differentMinMentions && elsewhere >= sorted.length * differentShare,
259
+ };
260
+ })
261
+ .sort((a, b) => b.spread - a.spread || a.name.localeCompare(b.name, "en"));
262
+ return {
263
+ names,
264
+ uneven: names.filter((row) => row.uneven),
265
+ // A name the voice renamed outright is worse than one it said two ways, so the worst share
266
+ // of mentions leads.
267
+ different: names
268
+ .filter((row) => row.different)
269
+ .toSorted((a, b) => (b.saidAs[0]?.mentions ?? 0) / b.mentions - (a.saidAs[0]?.mentions ?? 0) / a.mentions ||
270
+ b.mentions - a.mentions),
271
+ words: scored.length,
272
+ confidence: scored.length === 0 ? 0 : scored.reduce((sum, value) => sum + value, 0) / scored.length,
273
+ };
274
+ }
275
+ const list = (names, most = 6) => {
276
+ const shown = names.slice(0, most).map((row) => row.name);
277
+ const rest = names.length - shown.length;
278
+ return rest === 0 ? shown.join(", ") : `${shown.join(", ")} and ${String(rest)} more`;
279
+ };
280
+ // The one line before the narration is paid for. Names the names, says what will happen to them,
281
+ // and says the one place that fixes them for every video at once.
282
+ export function namesAheadNotice(result) {
283
+ const proper = result.names.filter((row) => row.proper);
284
+ if (proper.length === 0)
285
+ return null;
286
+ return (`${proper.length === 1 ? "1 name" : `${String(proper.length)} names`} in this article ` +
287
+ `${proper.length === 1 ? "has" : "have"} no pronunciation anywhere, so the voice will guess ` +
288
+ `${proper.length === 1 ? "it" : "them"}: ${list(proper)}. ` +
289
+ `To decide how they are said, add them to Settings → Voices → Name pronunciations, then make ` +
290
+ `the narration.`);
291
+ }
292
+ // The one line on the upload section, after the render: what the voice guessed, and what it was
293
+ // heard saying more than one way. Null when there is nothing to say.
294
+ export function narratedNotice(narrated, heard) {
295
+ const guessed = narrated?.names.filter((row) => row.proper) ?? [];
296
+ const uneven = heard?.uneven.filter((row) => !row.different) ?? [];
297
+ const different = heard?.different ?? [];
298
+ if (guessed.length === 0 && uneven.length === 0 && different.length === 0)
299
+ return null;
300
+ const parts = [];
301
+ // First, because it is the only finding that says the video is wrong rather than unguided.
302
+ if (different.length > 0)
303
+ parts.push(`${different.length === 1 ? "1 name came" : `${String(different.length)} names came`} out ` +
304
+ `as another sound (${different
305
+ .slice(0, 4)
306
+ .map((row) => `${row.name} as “${(row.saidAs[0]?.letters ?? "").toLowerCase()}”`)
307
+ .join(", ")}${different.length > 4 ? ` and ${String(different.length - 4)} more` : ""})`);
308
+ if (guessed.length > 0)
309
+ parts.push(`the voice guessed ${guessed.length === 1 ? "1 name" : `${String(guessed.length)} names`} ` +
310
+ `(${list(guessed)})`);
311
+ if (uneven.length > 0)
312
+ parts.push(`${uneven.length === 1 ? "1 name was" : `${String(uneven.length)} names were`} heard more ` +
313
+ `than one way (${list(uneven)})`);
314
+ const sentence = parts.length === 1
315
+ ? (parts[0] ?? "")
316
+ : `${parts.slice(0, -1).join(", ")}, and ${parts.at(-1) ?? ""}`;
317
+ return `Names in this narration: ${sentence}. Open the check to see every mention.`;
318
+ }
@@ -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;
@@ -0,0 +1,94 @@
1
+ import { z } from "zod";
2
+ import { fingerprint } from "../../kernel/runner/work.js";
3
+ import { actors } from "../agents/model.js";
4
+ import { deleteSetting, readJsonSetting, readSetting, writeSetting } from "../settings/repo.js";
5
+ import { libraryPronunciationsMax, parsePronunciationGlossary, } from "./pronunciation.js";
6
+ // Settings → Voices → Name pronunciations: one Pronunciation Glossary of your own, in the same
7
+ // `Term: /ipa/` lines an article's own glossary uses. Its entries come before the article's, so
8
+ // a name keeps the same pronunciation in every video however each article spells it out. A
9
+ // project takes a copy when it starts (and again when Edit project refreshes it), so a change
10
+ // here never alters a project's narration behind its back. Aliases on the Pronunciation page are
11
+ // the other half: they change the words, this changes the sounds.
12
+ export const libraryPronunciationsKey = "narration.pronunciations";
13
+ const savedKey = "narration.pronunciations.saved";
14
+ const savedRow = z
15
+ .object({ at: z.string().min(1).max(40), by: z.enum(actors) })
16
+ .strict()
17
+ .readonly();
18
+ export function readLibraryPronunciations(db) {
19
+ const saved = readJsonSetting(db, savedKey, savedRow, undefined);
20
+ return {
21
+ ...describeLibraryPronunciations(readSetting(db, libraryPronunciationsKey) ?? ""),
22
+ ...(saved === undefined ? {} : { savedAt: saved.at, savedBy: saved.by }),
23
+ };
24
+ }
25
+ export function describeLibraryPronunciations(text) {
26
+ const parsed = parsePronunciationGlossary(text);
27
+ return {
28
+ text,
29
+ terms: parsed.ok ? parsed.entries.length : 0,
30
+ version: versionOf(text),
31
+ ...(parsed.ok && parsed.skipped !== undefined ? { skipped: parsed.skipped } : {}),
32
+ ...(parsed.ok && parsed.unpronounced !== undefined
33
+ ? { unpronounced: parsed.unpronounced }
34
+ : {}),
35
+ };
36
+ }
37
+ // Short enough to pass around and read in a refusal, long enough never to collide.
38
+ export function versionOf(text) {
39
+ return fingerprint({ pronunciations: text.trim() }).slice(0, 16);
40
+ }
41
+ // `version` is the list the save was made from. It is required once a list exists: a save
42
+ // without it, or from an older one, is refused rather than replacing what somebody else wrote
43
+ // in another window or through an agent.
44
+ export function saveLibraryPronunciations(deps, text, version, by = "person") {
45
+ if (typeof text !== "string")
46
+ return {
47
+ ok: false,
48
+ reason: "refused",
49
+ detail: "Write the list as Term: /ipa/ lines, one name per line.",
50
+ };
51
+ if (text.length > libraryPronunciationsMax)
52
+ return {
53
+ ok: false,
54
+ reason: "refused",
55
+ detail: `Keep the list under ${String(libraryPronunciationsMax)} characters; it is ${String(text.length)} now. Remove the names you no longer narrate, then Save.`,
56
+ };
57
+ const current = readLibraryPronunciations(deps.db);
58
+ if (current.text !== "" && version !== current.version)
59
+ return {
60
+ ok: false,
61
+ reason: "conflict",
62
+ detail: conflictDetail(current, version),
63
+ current,
64
+ };
65
+ const trimmed = text.trim();
66
+ if (trimmed === "")
67
+ deleteSetting(deps.db, libraryPronunciationsKey);
68
+ else
69
+ writeSetting(deps.db, libraryPronunciationsKey, trimmed);
70
+ writeSetting(deps.db, savedKey, JSON.stringify({ at: deps.clock.now().toISOString(), by }));
71
+ return { ok: true, list: readLibraryPronunciations(deps.db) };
72
+ }
73
+ // Said to whoever sent the save: the person at the page, or an agent reading a refusal. It
74
+ // names what the list is now and the one step that keeps their own work.
75
+ function conflictDetail(current, version) {
76
+ const who = current.savedBy === "agent"
77
+ ? " by an agent"
78
+ : current.savedBy === "assistant"
79
+ ? " by the assistant"
80
+ : "";
81
+ const when = current.savedAt === undefined ? "" : ` at ${current.savedAt}`;
82
+ const names = `${String(current.terms)} ${current.terms === 1 ? "name" : "names"}`;
83
+ return version === undefined
84
+ ? `The list has ${names} already, saved${who}${when}. Read it first and send your save with the version it gives, so this one does not replace what is there.`
85
+ : `The list changed since this save was prepared: it has ${names} now, saved${who}${when}. Read it again and save with the version it gives; on the page, Save mine anyway keeps what is on screen.`;
86
+ }
87
+ // The entries a project's copy pronounces, under the project's own language and rules. An
88
+ // unparseable list is never fatal: its bad rows are skipped, and the editor is where they are
89
+ // named (the run's notices number the rows of the article's glossary, not of this list).
90
+ export function libraryGlossary(text, language, rules) {
91
+ if (text === undefined || text.trim() === "")
92
+ return { ok: true, entries: [] };
93
+ return parsePronunciationGlossary(text, language, rules);
94
+ }
@@ -12,6 +12,7 @@ import { splitEndMatter } from "../article/split.js";
12
12
  import { withEarlierEpisodes } from "../episodes/related.js";
13
13
  import { withoutSlopifyScene } from "../images/scenes.js";
14
14
  import { parsePronunciationGlossary, pronunciationRulesOf, } from "../narration/pronunciation.js";
15
+ import { libraryGlossary } from "../narration/pronunciations-library.js";
15
16
  import { withSharedGlossary } from "../narration/shared-glossary.js";
16
17
  import { researchDocuments } from "../research/documents.js";
17
18
  import { plannerMessages, subAgentMessages } from "../research/planner.js";
@@ -333,12 +334,23 @@ export function narrationGlossary(context) {
333
334
  const markdown = articleMarkdownOf(context);
334
335
  return glossaryOf(context, markdown === null ? null : splitEndMatter(markdown));
335
336
  }
336
- // The project's own glossary, then the other projects' pronunciations it copied, when it
337
- // shares them. Rows the own glossary had to skip are still reported.
337
+ // Your own pronunciation list first, then the project's own glossary, then the other projects'
338
+ // pronunciations it copied, when it shares them. The list wins so a name is said the same way in
339
+ // every video however this article spelled it out. Rows the own glossary had to skip are still
340
+ // reported; rows the list had to skip are named in its editor, where they can be fixed.
338
341
  function withShared(own, config) {
339
- if (!own.ok || config.audio?.shareGlossary !== true)
342
+ if (!own.ok)
340
343
  return own;
341
- return { ...own, entries: withSharedGlossary(own.entries, config.sharedGlossary) };
344
+ const list = libraryGlossary(config.libraryPronunciations, config.language, pronunciationRulesOf(config));
345
+ const shared = config.audio?.shareGlossary === true ? config.sharedGlossary : undefined;
346
+ if (!list.ok)
347
+ return list;
348
+ if (list.entries.length === 0 && shared === undefined)
349
+ return own;
350
+ return {
351
+ ...own,
352
+ entries: withSharedGlossary(list.entries, withSharedGlossary(own.entries, shared)),
353
+ };
342
354
  }
343
355
  function withSampleScope(prompt, sample) {
344
356
  return sample === undefined || sample.fullScript
@@ -33,6 +33,9 @@ export const wordsSchema = z.object({
33
33
  start: z.number().finite().nonnegative(),
34
34
  end: z.number().finite().positive(),
35
35
  confidence: z.number().optional(),
36
+ // What the timing model heard here, when that is not this word (`window.ts#heardAs`).
37
+ // Older timing files have it nowhere; a word said as written has it either way.
38
+ heard: z.string().optional(),
36
39
  // A multi-voice run's speaker id and script turn (`voices/timing.ts`).
37
40
  speaker: z.string().optional(),
38
41
  turn: z.number().int().positive().optional(),
@@ -40,6 +43,10 @@ export const wordsSchema = z.object({
40
43
  omissions: z
41
44
  .array(z.object({ start: z.number().finite().nonnegative(), text: z.string() }))
42
45
  .default([]),
46
+ // Whether this timing was written by a build that records what the model heard. Without it,
47
+ // a name with nothing against it was never listened to, which is not the same as a name that
48
+ // came out right, and the name check says so instead of guessing.
49
+ listened: z.boolean().default(false),
43
50
  });
44
51
  const cuesSchema = z.object({
45
52
  cues: z.array(z.object({
@@ -124,7 +131,12 @@ async function timing(deps, context, piece, snapshot) {
124
131
  if (captionCues(words).length === 0)
125
132
  throw new Error(`None of the narration could be matched to the article text, so captions can't be timed. The narration must be in the project's language (${editSettings.language}); if you uploaded your own audio, make sure it reads the article text, then Try again.`);
126
133
  context.signal.throwIfAborted();
127
- const output = preparedText(deps, context, piece, "subtitle_words", "subtitles.json", JSON.stringify({ key: piece.logicalFingerprint ?? piece.fingerprint, words, omissions }));
134
+ const output = preparedText(deps, context, piece, "subtitle_words", "subtitles.json", JSON.stringify({
135
+ key: piece.logicalFingerprint ?? piece.fingerprint,
136
+ words,
137
+ omissions,
138
+ listened: true,
139
+ }));
128
140
  await publishResult(deps, context, piece, [output], { words, omissions }, output.asset);
129
141
  return "done";
130
142
  }
@@ -83,7 +83,7 @@ It needs the Inworld TTS-2 model. With several speakers it works per speaker: ea
83
83
 
84
84
  | Option | What it does | Default |
85
85
  | --- | --- | --- |
86
- | **Use the article's pronunciation glossary** | Shown only for voices that read it. Reads names with the IPA in the article's Pronunciation Glossary, such as `Arda: /ˈɑɹdə/`, with no extra AI call. Works with Inworld TTS-2 and TTS-2 Flash. The written text stays unchanged. | On |
86
+ | **Use pronunciations** | Shown only for voices that read IPA. Reads names with the IPA they are given, with no extra AI call: your **Name pronunciations** list first, so a listed name sounds the same in every video, then the article's own Pronunciation Glossary, such as `Arda: /ˈɑɹdə/`. Works with Inworld TTS-2 and TTS-2 Flash. The written text stays unchanged. | On |
87
87
  | **Also use pronunciations from my other projects** | Adds every term from your other projects' glossaries, copied when this project starts. This project's own glossary wins where they differ. Turn it off to use only this article's glossary. | On |
88
88
 
89
89
  With another service or model the tick is not shown; your choice is kept for when you pick an Inworld TTS-2 voice.
@@ -62,6 +62,7 @@ When the narration was spoken as one whole request, the section says so: editing
62
62
 
63
63
  | Control | What it does |
64
64
  | --- | --- |
65
+ | **Use pronunciations** → **Copy the list again** | Copies your **Name pronunciations** (Settings → Voices) into this project again, so a name you corrected there is said the new way here too. A change to the list reaches the project only when you press this. After you save, only the chunks where a changed name appears are spoken again, paid per character. |
65
66
  | **Also use pronunciations from my other projects** → **Update from other projects** | Copies your other projects' Pronunciation Glossary terms into this project again, picking up any added since the last copy. After you save, only the chunks whose words those terms change are spoken again, paid per character. |
66
67
  | **Use your pronunciation list** → **Copy the list again** | Copies your Pronunciation list (Settings → Voices → **Pronunciation**) into this project again. A change to the list reaches the project only when you press this. After you save, only the chunks the changed aliases touch are spoken again. |
67
68
  | **Describe tables and figures in the narration** | The AI writes a short spoken passage for each table, figure, equation and code block. Turning it on costs one AI call per block, and after you save the chunks that contain a block are spoken again (the whole narration when it is spoken as one request). **Leave code out** skips code blocks. |