@gentbajko/slopify 1.5.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +34 -8
  2. package/dist/adapters/alignment/cache.js +67 -7
  3. package/dist/edge/cli.js +10 -2
  4. package/dist/edge/docker-launch.js +77 -0
  5. package/dist/edge/docker-projects/activation.js +35 -0
  6. package/dist/edge/docker-projects/claims.js +85 -0
  7. package/dist/edge/docker-projects/committed.js +40 -0
  8. package/dist/edge/docker-projects/engine.js +384 -0
  9. package/dist/edge/docker-projects/install.js +246 -0
  10. package/dist/edge/docker-projects/recover.js +100 -0
  11. package/dist/edge/docker-projects/state.js +221 -0
  12. package/dist/edge/docker-projects/tree.js +170 -0
  13. package/dist/edge/docker-projects/volume.js +95 -0
  14. package/dist/edge/docker.js +6 -0
  15. package/dist/edge/http/actions.js +64 -15
  16. package/dist/edge/http/app.js +6 -0
  17. package/dist/edge/http/folder-location-schema.js +11 -0
  18. package/dist/edge/http/folder-location.js +65 -0
  19. package/dist/edge/http/open-folder.js +2 -14
  20. package/dist/edge/http/projects.js +2 -0
  21. package/dist/edge/http/revision-files.js +2 -18
  22. package/dist/kernel/db/migrations/0012-project-recovery.sql +17 -0
  23. package/dist/kernel/lock.js +20 -6
  24. package/dist/main.js +11 -1
  25. package/dist/slices/admission/rules.js +6 -0
  26. package/dist/slices/admission/schema.js +3 -1
  27. package/dist/slices/article/plain.js +16 -9
  28. package/dist/slices/control/revision-control.js +2 -2
  29. package/dist/slices/narration/pronunciation-chunks.js +105 -0
  30. package/dist/slices/narration/pronunciation.js +126 -0
  31. package/dist/slices/narration/steering.js +82 -24
  32. package/dist/slices/play-drafts/convert.js +3 -1
  33. package/dist/slices/play-drafts/schema.js +3 -1
  34. package/dist/slices/rebuild/admission-repo.js +7 -4
  35. package/dist/slices/rebuild/recipe-audio-parts.js +70 -14
  36. package/dist/slices/rebuild/recipe-audio.js +61 -40
  37. package/dist/slices/rebuild/recipe-model.js +5 -3
  38. package/dist/slices/rebuild/recipe-preparation.js +6 -4
  39. package/dist/slices/rebuild/recipe-text.js +11 -3
  40. package/dist/slices/rebuild/recovery-conflict.js +30 -0
  41. package/dist/slices/rebuild/recovery-model.js +44 -0
  42. package/dist/slices/rebuild/recovery-repo.js +84 -0
  43. package/dist/slices/rebuild/recovery-selection.js +68 -0
  44. package/dist/slices/rebuild/recovery.js +257 -0
  45. package/dist/slices/rebuild/runtime-export-inputs.js +3 -2
  46. package/dist/slices/rebuild/runtime-narration-publication.js +30 -0
  47. package/dist/slices/rebuild/runtime-narration-reuse.js +20 -2
  48. package/dist/slices/rebuild/runtime-plan.js +8 -2
  49. package/dist/slices/rebuild/runtime-publication.js +9 -4
  50. package/dist/slices/rebuild/service.js +44 -22
  51. package/dist/slices/rebuild/transition-repo.js +1 -1
  52. package/dist/slices/revisions/mutation-assets.js +6 -1
  53. package/dist/slices/revisions/mutation-request.js +4 -0
  54. package/dist/slices/revisions/mutations.js +10 -5
  55. package/dist/slices/revisions/rules.js +43 -0
  56. package/dist/slices/revisions/schema.js +12 -0
  57. package/dist/slices/settings/repo.js +4 -2
  58. package/dist/web/assets/index-CZNiHm8P.css +1 -0
  59. package/dist/web/assets/index-DPgGLX5m.js +137 -0
  60. package/dist/web/index.html +2 -2
  61. package/package.json +3 -2
  62. package/scripts/docker-run.sh +1 -74
  63. package/dist/web/assets/index-CbGJtCio.js +0 -137
  64. package/dist/web/assets/index-DX1_chKs.css +0 -1
@@ -0,0 +1,126 @@
1
+ import { remark } from "remark";
2
+ import remarkGfm from "remark-gfm";
3
+ // Inworld requires English IPA, not every symbol in Unicode's IPA Extensions block.
4
+ // https://docs.inworld.ai/tts/capabilities/custom-pronunciation
5
+ const ipaAtom = "[ˈˌ]?[abdefghijklmnoprstuvwxzæðŋθɑɒɔəɚɛɜɝɡɪɹʃʊʌʒʔɫɾ][\\u0303\\u031a\\u0325\\u0329\\u032a\\u032c\\u032f\\u035c\\u0361ʰʲʷ]*[ːˑ]?";
6
+ const ipaSymbols = new RegExp(`^(?:${ipaAtom})+(?:\\.(?:${ipaAtom})+)*$`, "u");
7
+ function textOf(node) {
8
+ if (node.type === "break")
9
+ return "\n";
10
+ return node.value ?? node.children?.map(textOf).join("") ?? "";
11
+ }
12
+ function rowsOf(node) {
13
+ if (node.type === "heading") {
14
+ const text = textOf(node).trim();
15
+ return /^pronunciation glossary:?\s*$/iu.test(text) ? [] : [text];
16
+ }
17
+ if (node.type === "table") {
18
+ const rows = node.children ?? [];
19
+ return rows.slice(1).map((row) => {
20
+ const cells = row.children ?? [];
21
+ return cells.length >= 2 ? cells.slice(0, 2).map(textOf).join(": ") : textOf(row);
22
+ });
23
+ }
24
+ if (node.type === "paragraph")
25
+ return textOf(node).split(/\r?\n/u);
26
+ if (node.type === "root" || node.type === "list" || node.type === "listItem")
27
+ return (node.children ?? []).flatMap(rowsOf);
28
+ return ["(unsupported glossary block)"];
29
+ }
30
+ function refused(row, reason) {
31
+ return {
32
+ ok: false,
33
+ reason: "Pronunciation Glossary entry " +
34
+ row +
35
+ ": " +
36
+ reason +
37
+ ". Edit the glossary or turn off Use Pronunciation Glossary.",
38
+ };
39
+ }
40
+ function termIdentity(term) {
41
+ return Array.from(term, (character) => {
42
+ const lower = character.toLowerCase();
43
+ const folded = lower.toUpperCase().toLowerCase();
44
+ if (folded === character)
45
+ return character;
46
+ // Full case conversion can expand letters; only /iu-equivalent forms may share a key.
47
+ const match = new RegExp(`^${character.replace(/[|\\{}()[\]^$+*?.]/gu, "\\$&")}$`, "iu");
48
+ return match.test(folded) ? folded : match.test(lower) ? lower : character;
49
+ }).join("");
50
+ }
51
+ export function parsePronunciationGlossary(markdown) {
52
+ const rows = rowsOf(remark().use(remarkGfm).parse(markdown));
53
+ const entries = new Map();
54
+ for (const [index, row] of rows.entries()) {
55
+ const trimmed = row.trim();
56
+ if (trimmed === "" || /^pronunciation glossary:?\s*$/iu.test(trimmed))
57
+ continue;
58
+ const pair = /^([^:]+):\s*(.+)$/u.exec(trimmed);
59
+ const term = pair?.[1]?.trim().replace(/\s+/gu, " ");
60
+ const pronunciation = pair?.[2];
61
+ if (term === undefined ||
62
+ pronunciation === undefined ||
63
+ !/[\p{L}\p{N}]/u.test(term) ||
64
+ /[/[\]<>\p{Cc}]/u.test(term))
65
+ return refused(index + 1, "use Term: /IPA/ or a Term | IPA table");
66
+ const notation = /^(\/[^/]+\/(?:\s+\/[^/]+\/)*)(?:\s+[^/]+)?$/u.exec(pronunciation);
67
+ if (notation?.[1] === undefined)
68
+ return refused(index + 1, "use slash-delimited standard-English IPA");
69
+ const ipa = Array.from(notation[1].matchAll(/\/([^/]+)\//gu)).flatMap((match) => (match[1] ?? "").trim().split(/\s+/u));
70
+ if (ipa.length === 0 || ipa.some((word) => !ipaSymbols.test(word)))
71
+ return refused(index + 1, "use standard-English IPA, not ARPAbet or delivery tags");
72
+ if (term.split(" ").length !== ipa.length)
73
+ return refused(index + 1, "supply one IPA word for each written word");
74
+ const key = termIdentity(term);
75
+ const previous = entries.get(key);
76
+ if (previous !== undefined && previous.ipa.join(" ") !== ipa.join(" "))
77
+ return refused(index + 1, "conflicting pronunciations were supplied for the same term");
78
+ if (previous === undefined)
79
+ entries.set(key, { term, ipa });
80
+ }
81
+ return { ok: true, entries: [...entries.values()] };
82
+ }
83
+ export function pronunciationMatches(source, entries) {
84
+ if (entries.length === 0)
85
+ return [];
86
+ const ordered = [...entries].sort((a, b) => b.term.length - a.term.length);
87
+ const patterns = ordered.map((entry) => entry.term
88
+ .split(" ")
89
+ .map((word) => word.replace(/[|\\{}()[\]^$+*?.]/gu, "\\$&"))
90
+ .join("\\s+"));
91
+ const boundary = "[\\p{L}\\p{M}\\p{N}_\\-\u00ad\u2010\u2011\ufe63\uff0d]";
92
+ const expression = new RegExp("(?<!" +
93
+ boundary +
94
+ "['’]?)(?:" +
95
+ patterns.map((pattern) => `(${pattern})`).join("|") +
96
+ ")(?!['’]?" +
97
+ boundary +
98
+ ")", "giu");
99
+ const matches = [];
100
+ for (const match of source.matchAll(expression)) {
101
+ const end = match.index + match[0].length;
102
+ const before = source[match.index - 1];
103
+ const after = source[end];
104
+ if ((after === "'" && before !== "'") || (after === "’" && before !== "‘"))
105
+ continue;
106
+ const entry = ordered.find((_, index) => match[index + 1] !== undefined);
107
+ if (entry === undefined)
108
+ throw new Error("Matched glossary term has no mapping.");
109
+ matches.push({ start: match.index, end, entry });
110
+ }
111
+ return matches;
112
+ }
113
+ export function pronunciationSpans(source, entries) {
114
+ const spans = [];
115
+ for (const match of pronunciationMatches(source, entries)) {
116
+ const words = source.slice(match.start, match.end).matchAll(/\S+/gu);
117
+ for (const [index, word] of Array.from(words).entries()) {
118
+ const ipa = match.entry.ipa[index];
119
+ if (ipa === undefined)
120
+ throw new Error("Validated glossary term has no IPA word.");
121
+ const start = match.start + word.index;
122
+ spans.push({ start, end: start + word[0].length, text: `/${ipa}/` });
123
+ }
124
+ }
125
+ return spans;
126
+ }
@@ -1,9 +1,68 @@
1
1
  import { sourceSentences } from "./preparation.js";
2
2
  const cannotFit = {
3
3
  ok: false,
4
- reason: "Delivery cues or whitespace leave no room for narration at this model's character limit.",
4
+ reason: "Delivery cues, an indivisible IPA token or whitespace leave no room for narration at this model's character limit.",
5
5
  };
6
- export function prepareRequests(source, cues, maxCharacters) {
6
+ function atoms(source, from, to, spans) {
7
+ const result = [];
8
+ for (let at = from; at < to;) {
9
+ const span = spans.get(at);
10
+ if (span !== undefined) {
11
+ if (span.end > to || span.end <= at)
12
+ throw new Error("Pronunciation span crosses a word.");
13
+ result.push({ text: span.text, spokenText: source.slice(at, span.end) });
14
+ at = span.end;
15
+ }
16
+ else {
17
+ const point = source.codePointAt(at);
18
+ if (point === undefined)
19
+ throw new Error("Narration offset is outside the source.");
20
+ const text = String.fromCodePoint(point);
21
+ result.push({ text, spokenText: text });
22
+ at += text.length;
23
+ }
24
+ }
25
+ return result;
26
+ }
27
+ function checkedSpans(source, pronunciation) {
28
+ const spans = new Map();
29
+ let end = 0;
30
+ for (const span of [...pronunciation].sort((a, b) => a.start - b.start)) {
31
+ if (!Number.isInteger(span.start) ||
32
+ !Number.isInteger(span.end) ||
33
+ span.start < end ||
34
+ span.end <= span.start ||
35
+ span.end > source.length ||
36
+ !/^[^\s\uD800-\uDFFF]+$/u.test(source.slice(span.start, span.end)))
37
+ throw new Error("Pronunciation spans must cover disjoint whole source characters in a word.");
38
+ spans.set(span.start, span);
39
+ end = span.end;
40
+ }
41
+ return spans;
42
+ }
43
+ function anchoredParts(source, cues, spans) {
44
+ const bySentence = new Map();
45
+ for (const cue of cues) {
46
+ const group = bySentence.get(cue.sentence) ?? [];
47
+ group.push(cue);
48
+ bySentence.set(cue.sentence, group);
49
+ }
50
+ const anchors = new Map();
51
+ let offset = 0;
52
+ for (const sentence of sourceSentences(source)) {
53
+ const start = spans.find((span) => span.start < offset && offset < span.end)?.start ?? offset;
54
+ const events = anchors.get(start) ?? [];
55
+ events.push(...(bySentence.get(sentence.sentence) ?? []));
56
+ anchors.set(start, events);
57
+ offset += sentence.text.length;
58
+ }
59
+ return [...anchors].map(([start, events], index, boundaries) => ({
60
+ start,
61
+ text: source.slice(start, boundaries[index + 1]?.[0] ?? source.length),
62
+ events,
63
+ }));
64
+ }
65
+ export function prepareRequests(source, cues, maxCharacters, pronunciation = []) {
7
66
  if (!Number.isInteger(maxCharacters) || maxCharacters < 2)
8
67
  return {
9
68
  ok: false,
@@ -11,13 +70,8 @@ export function prepareRequests(source, cues, maxCharacters) {
11
70
  };
12
71
  if (source.trim() === "")
13
72
  return { ok: true, requests: [] };
73
+ const spans = checkedSpans(source, pronunciation);
14
74
  const requests = [];
15
- const bySentence = new Map();
16
- for (const cue of cues) {
17
- const group = bySentence.get(cue.sentence) ?? [];
18
- group.push(cue);
19
- bySentence.set(cue.sentence, group);
20
- }
21
75
  let active = null;
22
76
  let text = "";
23
77
  let spokenText = "";
@@ -29,17 +83,19 @@ export function prepareRequests(source, cues, maxCharacters) {
29
83
  spokenText = "";
30
84
  return true;
31
85
  };
32
- for (const sentence of sourceSentences(source)) {
33
- const events = bySentence.get(sentence.sentence) ?? [];
34
- const direction = events.find((cue) => cue.kind !== "sound");
86
+ for (const part of anchoredParts(source, cues, pronunciation)) {
87
+ const events = part.events;
88
+ const direction = pronunciation.length === 0
89
+ ? events.find((cue) => cue.kind !== "sound")
90
+ : events.findLast((cue) => cue.kind !== "sound");
35
91
  const tags = events.map(cueTag).join(" ");
36
92
  const prefix = tags === "" ? "" : `${tags} `;
37
- const first = Array.from(sentence.text)[0] ?? "";
38
- const words = sentence.text.match(/\S+\s*|\s+/gu) ?? [];
39
- const firstWord = words[0] ?? first;
93
+ const words = Array.from(part.text.matchAll(/\S+\s*|\s+/gu)).map((match) => atoms(source, part.start + match.index, part.start + match.index + match[0].length, spans));
94
+ const first = words[0]?.[0]?.text ?? "";
95
+ const firstWordLength = words[0]?.reduce((length, atom) => length + atom.text.length, 0) ?? 0;
40
96
  const freshPrefix = direction === undefined ? carryTag(active) : "";
41
- const minimum = firstWord.length + prefix.length + freshPrefix.length <= maxCharacters
42
- ? firstWord.length
97
+ const minimum = firstWordLength + prefix.length + freshPrefix.length <= maxCharacters
98
+ ? firstWordLength
43
99
  : first.length;
44
100
  if (spokenText.trim() !== "" && text.length + prefix.length + minimum > maxCharacters) {
45
101
  if (!flush())
@@ -52,24 +108,26 @@ export function prepareRequests(source, cues, maxCharacters) {
52
108
  text += prefix;
53
109
  if (direction !== undefined)
54
110
  active = direction.kind === "instruction" ? direction.text : null;
55
- for (const word of words) {
56
- if (word.length + carryTag(active).length <= maxCharacters &&
57
- text.length + word.length > maxCharacters &&
111
+ for (const [index, word] of words.entries()) {
112
+ const length = word.reduce((sum, atom) => sum + atom.text.length, 0);
113
+ if ((index > 0 || prefix === "" || spans.size === 0) &&
114
+ length + carryTag(active).length <= maxCharacters &&
115
+ text.length + length > maxCharacters &&
58
116
  spokenText.trim() !== "") {
59
117
  if (!flush())
60
118
  return cannotFit;
61
119
  text = carryTag(active);
62
120
  }
63
- for (const point of word) {
64
- if (text.length + point.length > maxCharacters) {
121
+ for (const atom of word) {
122
+ if (text.length + atom.text.length > maxCharacters) {
65
123
  if (!flush())
66
124
  return cannotFit;
67
125
  text = carryTag(active);
68
126
  }
69
- if (text.length + point.length > maxCharacters)
127
+ if (text.length + atom.text.length > maxCharacters)
70
128
  return cannotFit;
71
- text += point;
72
- spokenText += point;
129
+ text += atom.text;
130
+ spokenText += atom.spokenText;
73
131
  }
74
132
  }
75
133
  }
@@ -59,7 +59,9 @@ export function toAdmissionDraft(input) {
59
59
  format: form.format,
60
60
  sources,
61
61
  llm: form.llm,
62
- audio: sources.audio === "generate" ? form.audio : undefined,
62
+ audio: sources.audio === "generate" || form.audio.usePronunciationGlossary !== undefined
63
+ ? form.audio
64
+ : undefined,
63
65
  images: form.images,
64
66
  articlePrompt: sources.article === "generate" ? form.articlePrompt : undefined,
65
67
  ...(sources.audio === "generate" && form.narrationPrompt?.trim()
@@ -31,7 +31,9 @@ export const playDraftFormSchema = z
31
31
  .strict()
32
32
  .readonly(),
33
33
  llm: provider.readonly(),
34
- audio: provider.extend({ voice: text }).readonly(),
34
+ audio: provider
35
+ .extend({ voice: text, usePronunciationGlossary: z.boolean().optional() })
36
+ .readonly(),
35
37
  images: provider.readonly(),
36
38
  articlePrompt: text,
37
39
  narrationPrompt: text.optional(),
@@ -8,6 +8,7 @@ import { executionSnapshotSchema, planPreview, reviewStillCovers } from "./previ
8
8
  import { previewById } from "./repo.js";
9
9
  import { insertInvocation, reservationKey } from "./runtime-admission.js";
10
10
  import { bindNarrationReuse } from "./runtime-narration-reuse.js";
11
+ import { executionCatalogue } from "./runtime-plan.js";
11
12
  import { projectStandings } from "./runtime-store.js";
12
13
  export function admissionReceipt(db, projectId, idempotencyKey, requestHash) {
13
14
  const row = db
@@ -24,8 +25,9 @@ export function admissionReceipt(db, projectId, idempotencyKey, requestHash) {
24
25
  },
25
26
  };
26
27
  if (db
27
- .prepare("SELECT 1 FROM revision_mutations WHERE project_id=? AND idempotency_key=? UNION ALL SELECT 1 FROM project_control_receipts WHERE project_id=? AND idempotency_key=?")
28
- .get(projectId, idempotencyKey, projectId, idempotencyKey) !== undefined)
28
+ .prepare("SELECT 1 FROM revision_mutations WHERE project_id=? AND idempotency_key=? UNION ALL SELECT 1 FROM project_control_receipts WHERE project_id=? AND idempotency_key=? UNION ALL SELECT 1 FROM project_recovery_requests WHERE project_id=? AND idempotency_key=?")
29
+ .get(projectId, idempotencyKey, projectId, idempotencyKey, projectId, idempotencyKey) !==
30
+ undefined)
29
31
  return { ok: false, reason: "conflict" };
30
32
  return undefined;
31
33
  }
@@ -49,9 +51,10 @@ export function admitPreview(deps, input) {
49
51
  const view = getRevisionView(deps, preview.projectId, preview.baseRevisionId);
50
52
  if (view === undefined)
51
53
  return { ok: false, reason: "no-project" };
52
- const fresh = planPreview(deps, view, snapshot.catalogue, preview.selection, preview.id);
54
+ const fresh = planPreview(deps, view, input.planningCatalogue, preview.selection, preview.id);
53
55
  if (!fresh.ok || !reviewStillCovers(preview, snapshot, fresh.value))
54
56
  return { ok: false, reason: "stale-preview" };
57
+ const catalogue = executionCatalogue(input.planningCatalogue, view.revision.config);
55
58
  const admissionId = deps.ids.next();
56
59
  const workIds = [];
57
60
  for (const recipe of snapshot.recipes) {
@@ -97,7 +100,7 @@ export function admitPreview(deps, input) {
97
100
  const fingerprint = snapshot.anchors[key];
98
101
  if (fingerprint === undefined)
99
102
  throw new Error("Selected work has no reviewed desired anchor.");
100
- const work = insertInvocation(deps, view, recipe, snapshot.catalogue, { key, fingerprint }, false, view.revision.id, admissionId);
103
+ const work = insertInvocation(deps, view, recipe, catalogue, { key, fingerprint }, false, view.revision.id, admissionId);
101
104
  workIds.push(work.workId);
102
105
  }
103
106
  const receipt = {
@@ -1,8 +1,10 @@
1
- import { usesNarrationPreparation } from "../admission/rules.js";
1
+ import { usesNarrationPreparation, usesPronunciationGlossary } from "../admission/rules.js";
2
2
  import { narrationRegenerationToken, normalizeNarrationText, planNarration, } from "../narration/plan.js";
3
+ import { pronunciationSpans } from "../narration/pronunciation.js";
4
+ import { prepareRequests } from "../narration/steering.js";
3
5
  import { recipe } from "./recipe-model.js";
4
6
  import { preparationForGroup } from "./recipe-preparation.js";
5
- export function narrationParts(context, logicalKey, originalText, segment, dependsOn, preparations) {
7
+ export function narrationParts(context, logicalKey, originalText, segment, dependsOn, preparations, glossary) {
6
8
  const override = context.content.narrationOverrides[logicalKey];
7
9
  if (override?.kind === "asset")
8
10
  return [
@@ -13,25 +15,26 @@ export function narrationParts(context, logicalKey, originalText, segment, depen
13
15
  semantic: [normalizeNarrationText(originalText), voiceValues(context)],
14
16
  }, dependsOn, { tokenKey: logicalKey }),
15
17
  ];
18
+ if (glossary === null)
19
+ return [];
20
+ if (!glossary.ok)
21
+ return [refusedPart(context, logicalKey, dependsOn, glossary.reason)];
16
22
  const logicalText = normalizeNarrationText(override?.kind === "text" ? override.text : originalText);
23
+ const spans = pronunciationSpans(logicalText, glossary.entries);
17
24
  const wholeRequest = segment !== "body" || (context.config.chunking?.mode ?? "whole") === "whole";
18
25
  const choice = context.config.audio;
19
26
  const model = context.catalogue?.tts.find((row) => row.provider === choice?.provider &&
20
27
  row.id === choice.model &&
21
28
  row.enabled &&
22
29
  !row.deprecated);
30
+ const logicalLimit = Math.max(2, logicalText.length +
31
+ spans.reduce((sum, span) => sum + Math.max(0, span.text.length - (span.end - span.start)), 0));
32
+ const maxCharacters = model?.tts.maxCharacters ?? logicalLimit;
23
33
  if (usesNarrationPreparation(context.config)) {
24
- const prepared = preparationForGroup(context, logicalKey, logicalText, segment, dependsOn, model?.tts.maxCharacters ?? Math.max(2, logicalText.length));
34
+ const prepared = preparationForGroup(context, logicalKey, logicalText, segment, dependsOn, maxCharacters, spans);
25
35
  preparations.push(prepared.preparation);
26
36
  if (prepared.refusal !== null)
27
- return [
28
- recipe(context, `${logicalKey}:1`, "audio", {
29
- kind: "deferred",
30
- version: 1,
31
- operation: "resolve-revision-recipe",
32
- template: prepared.refusal,
33
- }, [prepared.preparation.key], { unresolved: true, refusal: prepared.refusal }),
34
- ];
37
+ return [refusedPart(context, logicalKey, [prepared.preparation.key], prepared.refusal)];
35
38
  return (prepared.requests ?? []).map(({ text, spokenText }, index) => recipe(context, `${logicalKey}:${index + 1}`, "audio", {
36
39
  kind: "tts",
37
40
  version: 1,
@@ -47,6 +50,26 @@ export function narrationParts(context, logicalKey, originalText, segment, depen
47
50
  wholeRequest,
48
51
  }, [prepared.preparation.key], { tokenKey: logicalKey, unresolved: logicalText.length === 0 }));
49
52
  }
53
+ if (spans.length > 0) {
54
+ const prepared = prepareRequests(logicalText, [], maxCharacters, spans);
55
+ if (!prepared.ok)
56
+ return [refusedPart(context, logicalKey, dependsOn, prepared.reason)];
57
+ return prepared.requests.map(({ text, spokenText }, index) => recipe(context, `${logicalKey}:${index + 1}`, "audio", {
58
+ kind: "tts",
59
+ version: 1,
60
+ provider: choice?.provider ?? "",
61
+ model: choice?.model ?? "",
62
+ voice: choice?.voice ?? "",
63
+ text,
64
+ spokenText,
65
+ logicalKey,
66
+ logicalText,
67
+ segment,
68
+ pronunciation: null,
69
+ wholeRequest,
70
+ }, dependsOn, { tokenKey: logicalKey, unresolved: logicalText.length === 0 }));
71
+ }
72
+ // Preserve the pre-feature splitter, shape and identities when no term matches.
50
73
  const requests = planNarration({
51
74
  groups: [
52
75
  {
@@ -60,9 +83,7 @@ export function narrationParts(context, logicalKey, originalText, segment, depen
60
83
  provider: choice?.provider ?? "",
61
84
  model: choice?.model ?? "",
62
85
  voice: choice?.voice ?? "",
63
- maxCharacters: context.catalogue === undefined
64
- ? Math.max(2, logicalText.length)
65
- : (model?.tts.maxCharacters ?? Math.max(2, logicalText.length)),
86
+ maxCharacters,
66
87
  retained: [],
67
88
  });
68
89
  return requests.map(({ text, key }) => recipe(context, key, "audio", {
@@ -79,6 +100,41 @@ export function narrationParts(context, logicalKey, originalText, segment, depen
79
100
  wholeRequest,
80
101
  }, dependsOn, { tokenKey: logicalKey, unresolved: logicalText.length === 0 }));
81
102
  }
103
+ function refusedPart(context, logicalKey, dependsOn, reason) {
104
+ return recipe(context, `${logicalKey}:1`, "audio", { kind: "deferred", version: 1, operation: "resolve-revision-recipe", template: reason }, dependsOn, { unresolved: true, refusal: reason });
105
+ }
106
+ export function pronunciationFutureValues(context, glossary, article, logicalKey, source) {
107
+ if (!usesPronunciationGlossary(context.config))
108
+ return [];
109
+ const override = context.content.narrationOverrides[logicalKey];
110
+ if (override?.kind === "asset")
111
+ return [];
112
+ if (glossary === null)
113
+ return [["pronunciation-glossary-v1", "pending-article", article.fingerprint]];
114
+ if (!glossary.ok)
115
+ return [["pronunciation-glossary-v1", "invalid", glossary.reason]];
116
+ // Unknown generated entry text may match any supplied term.
117
+ if (source === null)
118
+ return glossary.entries.length === 0
119
+ ? []
120
+ : [
121
+ [
122
+ "pronunciation-glossary-v1",
123
+ glossary.entries.map((entry) => [entry.term, [...entry.ipa]]),
124
+ ],
125
+ ];
126
+ const clean = normalizeNarrationText(override?.kind === "text" ? override.text : source);
127
+ const spans = pronunciationSpans(clean, glossary.entries);
128
+ return spans.length === 0
129
+ ? []
130
+ : [
131
+ [
132
+ "pronunciation-glossary-v1",
133
+ logicalKey,
134
+ spans.map((span) => [span.start, span.end, span.text]),
135
+ ],
136
+ ];
137
+ }
82
138
  export function voiceValues(context) {
83
139
  return [
84
140
  context.config.audio?.provider ?? null,
@@ -1,18 +1,28 @@
1
1
  import { fingerprint } from "../../kernel/runner/work.js";
2
- import { usesNarrationPreparation } from "../admission/rules.js";
3
- import { chunkNarration, defaultChunking } from "../narration/chunk.js";
2
+ import { usesNarrationPreparation, usesPronunciationGlossary } from "../admission/rules.js";
3
+ import { defaultChunking } from "../narration/chunk.js";
4
4
  import { concatArgs } from "../narration/concat.js";
5
5
  import { normalizeNarrationText } from "../narration/plan.js";
6
- import { narrationParts, voiceValues } from "./recipe-audio-parts.js";
6
+ import { pronunciationChunks } from "../narration/pronunciation-chunks.js";
7
+ import { narrationParts, pronunciationFutureValues, voiceValues } from "./recipe-audio-parts.js";
7
8
  import { recipe, resourceIdentity, selectedReference, } from "./recipe-model.js";
8
9
  import { narrationFileRecipe } from "./recipe-narration-text.js";
9
10
  import { preparationFuture, preparationTemplate } from "./recipe-preparation.js";
11
+ export function bodyNarrationGroups(context, text) {
12
+ return text.articleText === null
13
+ ? []
14
+ : pronunciationChunks(normalizeNarrationText(text.articleText), context.config.chunking ?? defaultChunking, usesPronunciationGlossary(context.config) && text.glossary?.ok ? text.glossary.entries : [], new Set(Object.keys(context.content.narrationOverrides)), context.content.narrationSources);
15
+ }
10
16
  export function audioRecipes(context, text) {
11
17
  const { config, content } = context;
12
18
  const recipes = [];
13
19
  if (config.sources.audio === "off")
14
20
  return { recipes, mediaFingerprint: null, timeline: [], keys: [] };
21
+ const prepare = usesNarrationPreparation(config);
22
+ const pronounce = usesPronunciationGlossary(config);
23
+ const narrationFiles = prepare || pronounce;
15
24
  let body;
25
+ let bodyTranscript = text.articleText ?? text.article.fingerprint;
16
26
  if (config.sources.audio === "provide") {
17
27
  body = recipe(context, "audio:provided", "audio", {
18
28
  kind: "provided",
@@ -23,26 +33,30 @@ export function audioRecipes(context, text) {
23
33
  recipes.push(body);
24
34
  }
25
35
  else {
26
- const groups = text.articleText === null
27
- ? []
28
- : chunkNarration(normalizeNarrationText(text.articleText), config.chunking ?? defaultChunking);
29
- const occurrences = new Map();
36
+ const groups = bodyNarrationGroups(context, text);
30
37
  const parts = [];
38
+ const transcripts = [];
39
+ const futurePronunciation = [];
31
40
  let pending = text.articleText === null;
32
- for (const logicalText of groups) {
33
- const hash = fingerprint(logicalText).slice(0, 20);
34
- const occurrence = (occurrences.get(hash) ?? 0) + 1;
35
- occurrences.set(hash, occurrence);
36
- const logicalKey = `audio:body:${hash}-${occurrence}`;
37
- const group = narrationParts(context, logicalKey, logicalText, "body", [text.article.key], recipes);
41
+ for (const { key: logicalKey, text: logicalText } of groups) {
42
+ transcripts.push({
43
+ original: logicalText,
44
+ effective: effectiveText(context, logicalKey, logicalText),
45
+ });
46
+ futurePronunciation.push(...pronunciationFutureValues(context, text.glossary, text.article, logicalKey, logicalText));
47
+ const group = narrationParts(context, logicalKey, logicalText, "body", [text.article.key], recipes, text.glossary);
38
48
  if (group.length === 0)
39
49
  pending = true;
40
50
  parts.push(...group);
41
51
  }
42
- if (text.articleText === null && usesNarrationPreparation(config))
43
- recipes.push(preparationFuture(context, "body", text.article));
44
- // Physical ordinals are stable only after every logical group's cue plan is known.
45
- if (pending && usesNarrationPreparation(config))
52
+ if (transcripts.some((row) => row.effective !== row.original))
53
+ bodyTranscript = ["narration-transcript-v1", transcripts.map((row) => row.effective)];
54
+ if (text.articleText === null) {
55
+ futurePronunciation.push(...pronunciationFutureValues(context, text.glossary, text.article, "audio:body:future", null));
56
+ if (prepare)
57
+ recipes.push(preparationFuture(context, "body", text.article));
58
+ }
59
+ if (pending && prepare)
46
60
  parts.length = 0;
47
61
  if (pending)
48
62
  parts.push(recipe(context, "audio:body:future", "audio", {
@@ -53,11 +67,12 @@ export function audioRecipes(context, text) {
53
67
  text.article.fingerprint,
54
68
  voiceValues(context),
55
69
  chunkingValues(context),
56
- ...(usesNarrationPreparation(config) ? [preparationTemplate(context)] : []),
70
+ ...(prepare ? [preparationTemplate(context)] : []),
71
+ ...futurePronunciation,
57
72
  ],
58
73
  }, [text.article.key, ...preparationKeys(recipes, "body")]));
59
74
  recipes.push(...parts);
60
- if (usesNarrationPreparation(config))
75
+ if (narrationFiles)
61
76
  recipes.push(narrationFileRecipe(context, "body", parts));
62
77
  body = recipe(context, "audio:body:concat", "audio", {
63
78
  kind: "local",
@@ -73,29 +88,25 @@ export function audioRecipes(context, text) {
73
88
  const ordered = [];
74
89
  for (const category of ["intro", "body", "outro"]) {
75
90
  if (category === "body") {
76
- ordered.push({ value: body, transcript: text.articleText ?? text.article.fingerprint });
91
+ ordered.push({ value: body, transcript: bodyTranscript });
77
92
  continue;
78
93
  }
79
94
  const entry = text.entries[category];
80
95
  if (entry === undefined)
81
96
  continue;
82
- if (entry.text === null && usesNarrationPreparation(config))
83
- recipes.push(preparationFuture(context, category, entry.recipe));
97
+ const logicalKey = `audio:${category}`;
98
+ const supplied = content.narrationOverrides[logicalKey]?.kind === "asset";
99
+ const dependencies = [entry.recipe.key, ...(pronounce && !supplied ? [text.article.key] : [])];
100
+ const invalidGlossary = !supplied && text.glossary !== null && !text.glossary.ok ? text.glossary.reason : undefined;
101
+ if (prepare &&
102
+ !supplied &&
103
+ invalidGlossary === undefined &&
104
+ (entry.text === null || text.glossary === null))
105
+ recipes.push(preparationFuture(context, category, entry.recipe, pronounce ? [text.article] : []));
84
106
  const parts = entry.text === null
85
- ? [
86
- recipe(context, `audio:${category}:future`, "audio", {
87
- kind: "deferred",
88
- version: 1,
89
- operation: `${category}-narration`,
90
- template: [
91
- entry.recipe.fingerprint,
92
- voiceValues(context),
93
- ...(usesNarrationPreparation(config) ? [preparationTemplate(context)] : []),
94
- ],
95
- }, [entry.recipe.key, ...preparationKeys(recipes, category)]),
96
- ]
97
- : narrationParts(context, `audio:${category}`, entry.text, category, [entry.recipe.key], recipes);
98
- if (parts.length === 0 && usesNarrationPreparation(config))
107
+ ? []
108
+ : narrationParts(context, logicalKey, entry.text, category, dependencies, recipes, text.glossary);
109
+ if (parts.length === 0)
99
110
  parts.push(recipe(context, `audio:${category}:future`, "audio", {
100
111
  kind: "deferred",
101
112
  version: 1,
@@ -103,9 +114,10 @@ export function audioRecipes(context, text) {
103
114
  template: [
104
115
  entry.recipe.fingerprint,
105
116
  voiceValues(context),
106
- preparationTemplate(context),
117
+ ...(prepare ? [preparationTemplate(context)] : []),
118
+ ...pronunciationFutureValues(context, text.glossary, text.article, logicalKey, entry.text),
107
119
  ],
108
- }, [entry.recipe.key, ...preparationKeys(recipes, category)]));
120
+ }, [...dependencies, ...preparationKeys(recipes, category)], invalidGlossary === undefined ? {} : { unresolved: true, refusal: invalidGlossary }));
109
121
  recipes.push(...parts);
110
122
  const audio = recipe(context, `audio:${category}`, "audio", {
111
123
  kind: "local",
@@ -117,9 +129,14 @@ export function audioRecipes(context, text) {
117
129
  ],
118
130
  }, parts.map((part) => part.key));
119
131
  recipes.push(audio);
120
- if (usesNarrationPreparation(config))
132
+ if (narrationFiles)
121
133
  recipes.push(narrationFileRecipe(context, category, parts));
122
- ordered.push({ value: audio, transcript: entry.text ?? entry.recipe.fingerprint });
134
+ ordered.push({
135
+ value: audio,
136
+ transcript: entry.text === null
137
+ ? entry.recipe.fingerprint
138
+ : effectiveText(context, logicalKey, entry.text),
139
+ });
123
140
  }
124
141
  const timeline = ordered.map(({ value, transcript }) => {
125
142
  const retained = context.manifest.outputs.find((row) => row.workKey === value.key && row.state === "ready" && selectedReference(row));
@@ -140,6 +157,10 @@ export function audioRecipes(context, text) {
140
157
  keys: ordered.map(({ value }) => value.key),
141
158
  };
142
159
  }
160
+ function effectiveText(context, key, original) {
161
+ const override = context.content.narrationOverrides[key];
162
+ return override?.kind === "text" ? normalizeNarrationText(override.text) : original;
163
+ }
143
164
  function chunkingValues(context) {
144
165
  const chunking = context.config.chunking ?? defaultChunking;
145
166
  return [