@gentbajko/slopify 4.2.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 +2 -0
- package/dist/adapters/alignment/protocol.js +1 -0
- package/dist/adapters/alignment/window.js +29 -5
- package/dist/edge/http/pronunciation-check.js +8 -3
- package/dist/edge/mcp/tools-library.js +1 -1
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/patch-notes/4.3.0.md +21 -0
- package/dist/patch-notes/index.json +6 -0
- package/dist/slices/narration/pronunciation-check.js +103 -11
- package/dist/slices/rebuild/runtime-subtitles.js +13 -1
- package/dist/tutorials/Narration-Aliases-and-Glossary.md +8 -0
- package/dist/web/assets/{index-CQASqwjT.js → index-B-ZU1ZP9.js} +165 -165
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
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
|
+
}
|
|
@@ -49,7 +49,8 @@ function timingWords(deps, projectId, view) {
|
|
|
49
49
|
if (timing === undefined)
|
|
50
50
|
return null;
|
|
51
51
|
try {
|
|
52
|
-
|
|
52
|
+
const parsed = wordsSchema.parse(JSON.parse(readFileSync(outputPath(deps.paths, projectId, timing.output.path), "utf8")));
|
|
53
|
+
return { words: parsed.words, listened: parsed.listened };
|
|
53
54
|
}
|
|
54
55
|
catch {
|
|
55
56
|
// Timing that is missing or unreadable means there is nothing to say about how it was heard.
|
|
@@ -115,18 +116,22 @@ export function pronunciationCheckRoutes(deps) {
|
|
|
115
116
|
revisionId: null,
|
|
116
117
|
narrated: null,
|
|
117
118
|
heard: null,
|
|
119
|
+
listened: false,
|
|
118
120
|
notice: null,
|
|
119
121
|
});
|
|
120
122
|
const sources = sourcesOf(view.revision.config, view);
|
|
121
123
|
const script = narrationText(deps, projectId, view, "tts_script");
|
|
122
124
|
const written = narrationText(deps, projectId, view, "narration_txt");
|
|
123
|
-
const
|
|
125
|
+
const timing = timingWords(deps, projectId, view);
|
|
124
126
|
const narrated = script === null ? null : namesNarrated(script, sources);
|
|
125
|
-
const heard =
|
|
127
|
+
const heard = timing === null
|
|
128
|
+
? null
|
|
129
|
+
: heardNames(timing.words, sources, written ?? script ?? undefined);
|
|
126
130
|
return c.json({
|
|
127
131
|
revisionId: view.revision.id,
|
|
128
132
|
narrated,
|
|
129
133
|
heard,
|
|
134
|
+
listened: timing?.listened ?? false,
|
|
130
135
|
notice: narratedNotice(narrated, heard),
|
|
131
136
|
});
|
|
132
137
|
}));
|
|
@@ -170,7 +170,7 @@ export const libraryTools = [
|
|
|
170
170
|
name: "check_narration_names",
|
|
171
171
|
route: "GET /api/projects/:projectId/pronunciations/heard",
|
|
172
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
|
|
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
174
|
},
|
|
175
175
|
{
|
|
176
176
|
name: "get_narration_aliases",
|
|
Binary file
|
|
Binary file
|
|
@@ -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.
|
|
@@ -7,6 +7,47 @@ import { withSharedGlossary } from "./shared-glossary.js";
|
|
|
7
7
|
// flags. Eight mentions is the fewest from which the 10th and 90th percentiles mean anything.
|
|
8
8
|
export const heardMinMentions = 8;
|
|
9
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
|
+
}
|
|
10
51
|
// The entries narration pronounces with, assembled the way `recipe-text.ts` assembles them.
|
|
11
52
|
// Parsed twice: once under the rules the project narrates with, which is what actually guides
|
|
12
53
|
// the voice, and once under the current rules, which is the fullest list of names the sources
|
|
@@ -154,25 +195,56 @@ corpus) {
|
|
|
154
195
|
// A word of the timing is one word, so a term of several reaches it a part at a time, and a
|
|
155
196
|
// hyphenated name may arrive whole or in parts. A part that is an ordinary word of this text
|
|
156
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.
|
|
157
200
|
for (const entry of nameCandidates(listed, heardText))
|
|
158
|
-
for (const part of [entry.term, ...entry.term.split(/[\s‐‑-]+/u)])
|
|
159
|
-
if (part.length >= 3 &&
|
|
160
|
-
|
|
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 : [] });
|
|
161
209
|
const scored = words.flatMap((word) => typeof word.confidence === "number" ? [word.confidence] : []);
|
|
162
210
|
const by = new Map();
|
|
163
211
|
for (const word of words) {
|
|
164
212
|
if (typeof word.confidence !== "number")
|
|
165
213
|
continue;
|
|
166
|
-
const
|
|
167
|
-
if (
|
|
214
|
+
const found = wanted.get(spokenName(word.text).toLowerCase());
|
|
215
|
+
if (found === undefined)
|
|
168
216
|
continue;
|
|
169
|
-
|
|
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);
|
|
170
228
|
}
|
|
171
229
|
const names = [...by.entries()]
|
|
172
|
-
.map(([name,
|
|
173
|
-
const sorted = [...
|
|
230
|
+
.map(([name, row]) => {
|
|
231
|
+
const sorted = [...row.scores].sort((a, b) => a - b);
|
|
174
232
|
const low = percentile(sorted, 0.1);
|
|
175
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;
|
|
176
248
|
return {
|
|
177
249
|
name,
|
|
178
250
|
mentions: sorted.length,
|
|
@@ -181,12 +253,21 @@ corpus) {
|
|
|
181
253
|
high,
|
|
182
254
|
spread: high - low,
|
|
183
255
|
uneven: sorted.length >= heardMinMentions && high - low >= heardSpreadFlag,
|
|
256
|
+
saidAs,
|
|
257
|
+
ipa: row.ipa,
|
|
258
|
+
different: sorted.length >= differentMinMentions && elsewhere >= sorted.length * differentShare,
|
|
184
259
|
};
|
|
185
260
|
})
|
|
186
261
|
.sort((a, b) => b.spread - a.spread || a.name.localeCompare(b.name, "en"));
|
|
187
262
|
return {
|
|
188
263
|
names,
|
|
189
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),
|
|
190
271
|
words: scored.length,
|
|
191
272
|
confidence: scored.length === 0 ? 0 : scored.reduce((sum, value) => sum + value, 0) / scored.length,
|
|
192
273
|
};
|
|
@@ -212,15 +293,26 @@ export function namesAheadNotice(result) {
|
|
|
212
293
|
// heard saying more than one way. Null when there is nothing to say.
|
|
213
294
|
export function narratedNotice(narrated, heard) {
|
|
214
295
|
const guessed = narrated?.names.filter((row) => row.proper) ?? [];
|
|
215
|
-
const uneven = heard?.uneven ?? [];
|
|
216
|
-
|
|
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)
|
|
217
299
|
return null;
|
|
218
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` : ""})`);
|
|
219
308
|
if (guessed.length > 0)
|
|
220
309
|
parts.push(`the voice guessed ${guessed.length === 1 ? "1 name" : `${String(guessed.length)} names`} ` +
|
|
221
310
|
`(${list(guessed)})`);
|
|
222
311
|
if (uneven.length > 0)
|
|
223
312
|
parts.push(`${uneven.length === 1 ? "1 name was" : `${String(uneven.length)} names were`} heard more ` +
|
|
224
313
|
`than one way (${list(uneven)})`);
|
|
225
|
-
|
|
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.`;
|
|
226
318
|
}
|
|
@@ -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({
|
|
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
|
}
|
|
@@ -181,6 +181,14 @@ The **YouTube** section carries the same line, saying two things.
|
|
|
181
181
|
|
|
182
182
|
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 10th and 90th percentile.
|
|
183
183
|
|
|
184
|
+
**What came out.** The timing model writes down the letters it heard over a word whenever they are not the word itself, so **Came out as** shows how a name was actually said, next to how many of its mentions came out that way — `“eltarel” · 21 of 21` — and **You asked for** shows the IPA your glossary gave it. This is the one part of the check that can catch a name said wrong the *same* way every time: that name scores high and scores evenly, so nothing above it would ever flag.
|
|
185
|
+
|
|
186
|
+
Spelling is folded away before anything is compared, because the model writes what it hears and has no idea how the name is spelled. It writes “creeg” for Kreeg, “bell” for Bel, “tier” for Tyr and “drist” for Drizzt — all of them the name said correctly — and none of those is reported. What is reported is a sound the name does not spell, in most of its mentions: six in ten or more, over at least three.
|
|
187
|
+
|
|
188
|
+
Read the letters rather than trust them: they come from a second model listening, not from a verdict, and a word that is spelled unlike how it sounds can still be named although it was said right. If what came out is wrong, change the name's IPA in **Name pronunciations** and narrate again; if it sounds right to you, nothing needs doing.
|
|
189
|
+
|
|
190
|
+
If the captions were timed before Slopify started keeping this, the check says so instead of calling the names right. Making the captions again fills it in and costs nothing but time.
|
|
191
|
+
|
|
184
192
|
A name read as an ordinary English word is left out of both checks: a one-word name that the same text also writes in lower case is a word here and not a name, which is why "Hall" stays quiet in a video that also says "hall".
|
|
185
193
|
|
|
186
194
|
## Which one do I use?
|