@storyteller-platform/align 0.1.57 → 0.1.58

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 (118) hide show
  1. package/dist/align/align.cjs +38 -19
  2. package/dist/align/align.d.cts +12 -1
  3. package/dist/align/align.d.ts +12 -1
  4. package/dist/align/align.js +38 -19
  5. package/dist/align/ctc/Aligner.cjs +800 -0
  6. package/dist/align/ctc/Aligner.d.cts +56 -0
  7. package/dist/align/ctc/Aligner.d.ts +56 -0
  8. package/dist/align/ctc/Aligner.js +789 -0
  9. package/dist/align/ctc/align.cjs +189 -0
  10. package/dist/align/ctc/align.d.cts +14 -0
  11. package/dist/align/ctc/align.d.ts +14 -0
  12. package/dist/align/ctc/align.js +132 -0
  13. package/dist/{process/mime.cjs → align/ctc/emissions.cjs} +7 -20
  14. package/dist/align/ctc/emissions.d.cts +6 -0
  15. package/dist/align/ctc/emissions.d.ts +6 -0
  16. package/dist/align/ctc/emissions.js +7 -0
  17. package/dist/align/ctc/forcedAlign.cjs +273 -0
  18. package/dist/align/ctc/forcedAlign.d.cts +21 -0
  19. package/dist/align/ctc/forcedAlign.d.ts +21 -0
  20. package/dist/align/ctc/forcedAlign.js +248 -0
  21. package/dist/align/ctc/greedyDecode.cjs +61 -0
  22. package/dist/align/ctc/greedyDecode.d.cts +15 -0
  23. package/dist/align/ctc/greedyDecode.d.ts +15 -0
  24. package/dist/align/ctc/greedyDecode.js +37 -0
  25. package/dist/align/ctc/mediaOverlay.cjs +248 -0
  26. package/dist/align/ctc/mediaOverlay.d.cts +16 -0
  27. package/dist/align/ctc/mediaOverlay.d.ts +16 -0
  28. package/dist/align/ctc/mediaOverlay.js +224 -0
  29. package/dist/align/ctc/report.cjs +66 -0
  30. package/dist/align/ctc/report.d.cts +79 -0
  31. package/dist/align/ctc/report.d.ts +79 -0
  32. package/dist/align/ctc/report.js +42 -0
  33. package/dist/align/ctc/search.cjs +437 -0
  34. package/dist/align/ctc/search.d.cts +46 -0
  35. package/dist/align/ctc/search.d.ts +46 -0
  36. package/dist/align/ctc/search.js +412 -0
  37. package/dist/align/getSentenceRanges.cjs +9 -3
  38. package/dist/align/getSentenceRanges.d.cts +1 -0
  39. package/dist/align/getSentenceRanges.d.ts +1 -0
  40. package/dist/align/getSentenceRanges.js +9 -3
  41. package/dist/align/parse.cjs +17 -4
  42. package/dist/align/parse.d.cts +8 -4
  43. package/dist/align/parse.d.ts +8 -4
  44. package/dist/align/parse.js +19 -4
  45. package/dist/align/search.cjs +76 -23
  46. package/dist/align/search.d.cts +8 -4
  47. package/dist/align/search.d.ts +8 -4
  48. package/dist/align/search.js +76 -24
  49. package/dist/align/slugify.cjs +3 -2
  50. package/dist/align/slugify.d.cts +1 -1
  51. package/dist/align/slugify.d.ts +1 -1
  52. package/dist/align/slugify.js +3 -2
  53. package/dist/cli/bin.cjs +204 -114
  54. package/dist/cli/bin.js +194 -103
  55. package/dist/common/ffmpeg.cjs +10 -5
  56. package/dist/common/ffmpeg.js +10 -5
  57. package/dist/common/logging.cjs +33 -1
  58. package/dist/common/logging.js +33 -1
  59. package/dist/emit/emit.cjs +105 -0
  60. package/dist/emit/emit.d.cts +15 -0
  61. package/dist/emit/emit.d.ts +15 -0
  62. package/dist/emit/emit.js +86 -0
  63. package/dist/emit/fs.cjs +227 -0
  64. package/dist/emit/fs.d.cts +22 -0
  65. package/dist/emit/fs.d.ts +22 -0
  66. package/dist/emit/fs.js +162 -0
  67. package/dist/emit/parse.cjs +68 -0
  68. package/dist/emit/parse.d.cts +26 -0
  69. package/dist/emit/parse.d.ts +26 -0
  70. package/dist/emit/parse.js +58 -0
  71. package/dist/index.d.cts +1 -1
  72. package/dist/index.d.ts +1 -1
  73. package/dist/markup/model.cjs +0 -16
  74. package/dist/markup/model.d.cts +1 -1
  75. package/dist/markup/model.d.ts +1 -1
  76. package/dist/markup/model.js +0 -12
  77. package/dist/markup/parseDom.cjs +13 -14
  78. package/dist/markup/parseDom.d.cts +2 -2
  79. package/dist/markup/parseDom.d.ts +2 -2
  80. package/dist/markup/parseDom.js +13 -18
  81. package/dist/markup/resolvedPos.d.cts +1 -1
  82. package/dist/markup/resolvedPos.d.ts +1 -1
  83. package/dist/markup/segmentation.cjs +3 -1
  84. package/dist/markup/segmentation.js +3 -1
  85. package/dist/markup/serializeDom.cjs +1 -1
  86. package/dist/markup/serializeDom.d.cts +1 -1
  87. package/dist/markup/serializeDom.d.ts +1 -1
  88. package/dist/markup/serializeDom.js +1 -1
  89. package/dist/markup/transform.cjs +5 -2
  90. package/dist/markup/transform.d.cts +1 -1
  91. package/dist/markup/transform.d.ts +1 -1
  92. package/dist/markup/transform.js +5 -4
  93. package/dist/{model-CZ2mMHJO.d.cts → model-CeOMDDgt.d.cts} +3 -11
  94. package/dist/{model-CZ2mMHJO.d.ts → model-CeOMDDgt.d.ts} +3 -11
  95. package/dist/process/processAudiobook.cjs +12 -23
  96. package/dist/process/processAudiobook.js +14 -30
  97. package/dist/process/ranges.cjs +2 -2
  98. package/dist/process/ranges.js +2 -2
  99. package/dist/readium/guidedNavigation.cjs +8 -8
  100. package/dist/readium/guidedNavigation.js +8 -8
  101. package/dist/readium/manifest.cjs +5 -4
  102. package/dist/readium/manifest.js +5 -4
  103. package/dist/snapshot/ctc/snapshot.cjs +261 -0
  104. package/dist/snapshot/ctc/snapshot.d.cts +6 -0
  105. package/dist/snapshot/ctc/snapshot.d.ts +6 -0
  106. package/dist/snapshot/ctc/snapshot.js +200 -0
  107. package/dist/snapshot/parse.cjs +29 -16
  108. package/dist/snapshot/parse.d.cts +14 -10
  109. package/dist/snapshot/parse.d.ts +14 -10
  110. package/dist/snapshot/parse.js +32 -17
  111. package/dist/transcribe/parse.d.cts +6 -6
  112. package/dist/transcribe/parse.d.ts +6 -6
  113. package/dist/transcribe/transcribe.cjs +4 -2
  114. package/dist/transcribe/transcribe.js +4 -2
  115. package/package.json +6 -4
  116. package/dist/process/mime.d.cts +0 -3
  117. package/dist/process/mime.d.ts +0 -3
  118. package/dist/process/mime.js +0 -24
@@ -0,0 +1,789 @@
1
+ import "../../chunk-BIEQXUOY.js";
2
+ import { readFile } from "node:fs/promises";
3
+ import {
4
+ basename,
5
+ dirname,
6
+ parse,
7
+ parse as autoParse,
8
+ relative
9
+ } from "node:path/posix";
10
+ import { enumerate, max } from "itertools";
11
+ import memoize from "memoize";
12
+ import { Epub } from "@storyteller-platform/epub";
13
+ import {
14
+ createAggregator,
15
+ createTiming
16
+ } from "@storyteller-platform/ghost-story";
17
+ import { MediaType } from "@storyteller-platform/media-types";
18
+ import { parseDom } from "../../markup/parseDom.js";
19
+ import { segmentChapter } from "../../markup/segmentation.js";
20
+ import { inlineFootnotes, liftText } from "../../markup/transform.js";
21
+ import {
22
+ collapseSentenceRangeGaps,
23
+ expandEmptySentenceRanges,
24
+ getChapterDuration
25
+ } from "../getSentenceRanges.js";
26
+ import {
27
+ interpolateSentenceRanges,
28
+ slotKey
29
+ } from "../interpolateSentenceRanges.js";
30
+ import { slugify } from "../slugify.js";
31
+ import { framesToSeconds } from "./emissions.js";
32
+ import {
33
+ ctcForcedAlign,
34
+ detectDroppedLabels
35
+ } from "./forcedAlign.js";
36
+ import { buildFragmentMaps, createMediaOverlay } from "./mediaOverlay.js";
37
+ import {
38
+ buildAudioFileContexts,
39
+ updateAudioFileReports
40
+ } from "./report.js";
41
+ import { findCtcBoundaries } from "./search.js";
42
+ const MAX_SEARCH_LENGTH = 4e4;
43
+ class Aligner {
44
+ constructor(epub, audiofiles, emissionsReader, granularity, textRef, languageOverride, logger) {
45
+ this.epub = epub;
46
+ this.audiofiles = audiofiles;
47
+ this.emissionsReader = emissionsReader;
48
+ this.languageOverride = languageOverride;
49
+ this.logger = logger;
50
+ this.audiofileFrames = /* @__PURE__ */ new Map();
51
+ this.audiofileEmissionTimings = /* @__PURE__ */ new Map();
52
+ for (const [i, audiofile] of enumerate(audiofiles)) {
53
+ const [, framesStart, framesEnd] = emissionsReader.frameMap[i];
54
+ const timings = emissionsReader.timings[i];
55
+ const length = framesEnd - framesStart;
56
+ this.audiofileFrames.set(audiofile, [framesStart, framesEnd]);
57
+ this.audioFileDurations[audiofile] = framesToSeconds(length, timings);
58
+ this.audiofileEmissionTimings.set(audiofile, timings);
59
+ }
60
+ this.getChapterSentences = memoize(this.getChapterSentences.bind(this));
61
+ this.granularity = granularity ?? "sentence";
62
+ this.textRef = textRef ?? "id-fragment";
63
+ }
64
+ audiofileFrames;
65
+ audiofileEmissionTimings;
66
+ totalDuration = 0;
67
+ alignedChapters = [];
68
+ timing = createAggregator();
69
+ granularity;
70
+ textRef;
71
+ audioFileDurations = {};
72
+ report = {
73
+ chapters: [],
74
+ unalignedChapters: [],
75
+ audioFiles: [],
76
+ unalignedAudioFiles: []
77
+ };
78
+ async getChapterSentences(chapterId) {
79
+ const chapterXml = await this.epub.readXhtmlItemContents(chapterId);
80
+ const original = parseDom(Epub.getXhtmlBody(chapterXml));
81
+ const inlined = inlineFootnotes(original);
82
+ const lifted = liftText(inlined.root);
83
+ const segmentation = await segmentChapter(lifted.result, {
84
+ primaryLocale: this.languageOverride ?? await this.epub.getLanguage()
85
+ });
86
+ return segmentation.filter((s) => s.text.match(/\S/));
87
+ }
88
+ async writeAlignedChapter(alignedChapter) {
89
+ const locale = this.languageOverride ?? await this.epub.getLanguage() ?? new Intl.Locale("en-US");
90
+ const { chapter, sentenceRanges, wordRanges, xml } = alignedChapter;
91
+ const sentences = await this.getChapterSentences(chapter.id);
92
+ const {
93
+ sentenceIdToBlockFragment,
94
+ sentenceIdToFragment,
95
+ wordIdToFragment,
96
+ wordRangeMap
97
+ } = buildFragmentMaps(
98
+ sentenceRanges,
99
+ wordRanges,
100
+ sentences,
101
+ locale,
102
+ this.textRef,
103
+ this.granularity
104
+ );
105
+ await this.addChapterAudio(sentenceRanges);
106
+ const mediaOverlayId = await this.addMediaOverlay(
107
+ chapter,
108
+ sentenceRanges,
109
+ wordRangeMap,
110
+ sentenceIdToBlockFragment,
111
+ sentenceIdToFragment,
112
+ wordIdToFragment
113
+ );
114
+ await this.epub.updateManifestItem(chapter.id, {
115
+ ...chapter,
116
+ mediaOverlay: mediaOverlayId
117
+ });
118
+ await this.epub.writeXhtmlItemContents(chapter.id, xml);
119
+ const chapterDuration = getChapterDuration(sentenceRanges);
120
+ await this.epub.addMetadata({
121
+ type: "meta",
122
+ properties: {
123
+ property: "media:duration",
124
+ refines: `#${mediaOverlayId}`
125
+ },
126
+ value: Epub.formatSmilDuration(chapterDuration)
127
+ });
128
+ }
129
+ async addChapterAudio(sentenceRanges) {
130
+ const audiofiles = Array.from(
131
+ new Set(sentenceRanges.map(({ audiofile }) => audiofile))
132
+ );
133
+ await Promise.all(
134
+ audiofiles.map(async (audiofile) => {
135
+ const { name, base } = autoParse(audiofile);
136
+ const id = `audio_${name}`;
137
+ const manifest = await this.epub.getManifest();
138
+ if (id in manifest) return;
139
+ const epubAudioFilename = `Audio/${base}`;
140
+ const duration = this.audioFileDurations[audiofile];
141
+ this.totalDuration += duration;
142
+ const audio = await readFile(audiofile);
143
+ const mediaType = MediaType.fromPath(base)?.mime;
144
+ await this.epub.addManifestItem(
145
+ {
146
+ id,
147
+ href: epubAudioFilename,
148
+ mediaType
149
+ },
150
+ audio
151
+ );
152
+ })
153
+ );
154
+ }
155
+ async addMediaOverlay(chapter, sentenceRanges, wordRangeMap, sentenceIdToBlockFragment, sentenceIdToFragment, wordIdToFragment) {
156
+ const { name: chapterStem } = parse(chapter.href);
157
+ const mediaOverlayId = `${chapter.id}_overlay`;
158
+ await this.epub.addManifestItem(
159
+ {
160
+ id: mediaOverlayId,
161
+ href: `MediaOverlays/${chapterStem}.smil`,
162
+ mediaType: "application/smil+xml"
163
+ },
164
+ createMediaOverlay(
165
+ chapter,
166
+ this.granularity,
167
+ sentenceRanges,
168
+ wordRangeMap,
169
+ sentenceIdToBlockFragment,
170
+ sentenceIdToFragment,
171
+ wordIdToFragment
172
+ ),
173
+ "xml"
174
+ );
175
+ return mediaOverlayId;
176
+ }
177
+ addChapterReport(spineIndex, chapter, chunk, score, chapterSentences, sentenceCount, sentenceRanges, startSentence, endSentence, alignedEmissionsStart, alignedEmissionsEnd) {
178
+ const audioFiles = buildAudioFileContexts(sentenceRanges);
179
+ this.report.chapters.push({
180
+ href: chapter.href,
181
+ spineIndex,
182
+ chunk,
183
+ score,
184
+ transcriptionOffset: alignedEmissionsStart,
185
+ endTranscriptionOffset: alignedEmissionsEnd,
186
+ firstMatchedSentenceId: startSentence,
187
+ firstMatchedSentenceContext: {
188
+ prevSentence: chapterSentences[startSentence - 1]?.text ?? null,
189
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
190
+ matchedSentence: chapterSentences[startSentence].text,
191
+ nextSentence: chapterSentences[startSentence + 1]?.text ?? null
192
+ },
193
+ lastMatchedSentenceId: endSentence,
194
+ lastMatchedSentenceContext: {
195
+ prevSentence: chapterSentences[endSentence - 1]?.text ?? null,
196
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
197
+ matchedSentence: chapterSentences[endSentence].text,
198
+ nextSentence: chapterSentences[endSentence + 1]?.text ?? null
199
+ },
200
+ chapterSentenceCount: sentenceCount,
201
+ alignedSentenceCount: sentenceRanges.length,
202
+ audioFiles
203
+ });
204
+ updateAudioFileReports(this.report, audioFiles, this.audioFileDurations);
205
+ }
206
+ async getSentenceRanges(chapterId, slugifiedChapter, sentenceStarts, wordStarts, sentenceIndexOffset, emissionsStart, emissionsEnd, anchors, paddedChapterBoundaries, unpaddedChapterBoundaries) {
207
+ const SENTENCE_PAD = 25;
208
+ const WORD_PAD = 8;
209
+ const windowAnchors = anchors.filter(
210
+ (a) => a.position > emissionsStart && a.position < emissionsEnd && a.position - emissionsStart >= a.offset && emissionsEnd - a.position >= slugifiedChapter.length - a.offset
211
+ ).map((a) => ({ ...a, position: a.position - emissionsStart }));
212
+ const alignments = ctcForcedAlign(
213
+ slugifiedChapter,
214
+ await this.emissionsReader.readFrames(emissionsStart, emissionsEnd),
215
+ windowAnchors,
216
+ this.emissionsReader.vocab,
217
+ this.emissionsReader.blankId,
218
+ (labelOffset, alignments2) => sentenceStarts.filter(
219
+ ([s, e]) => s >= labelOffset && e < labelOffset + alignments2.length && detectDroppedLabels(alignments2, s - labelOffset, e - labelOffset)
220
+ )
221
+ );
222
+ const finiteScores = alignments.filter(({ score: score2 }) => score2 !== -Infinity);
223
+ const unscaledScore = finiteScores.reduce((acc, { score: score2 }) => acc + score2, 0) / finiteScores.length;
224
+ const score = finiteScores.length > 0 ? Math.max((unscaledScore + 10) * 10, 0) : 0;
225
+ const {
226
+ sentenceRanges,
227
+ skippedSentences,
228
+ firstFoundSentence,
229
+ lastFoundSentence,
230
+ firstConfirmedSentence,
231
+ lastConfirmedSentence
232
+ } = this.buildSentenceRanges(
233
+ alignments,
234
+ sentenceStarts,
235
+ sentenceIndexOffset,
236
+ emissionsStart,
237
+ SENTENCE_PAD,
238
+ chapterId,
239
+ paddedChapterBoundaries,
240
+ unpaddedChapterBoundaries
241
+ );
242
+ const wordRanges = this.buildWordRanges(
243
+ alignments,
244
+ wordStarts,
245
+ sentenceIndexOffset,
246
+ emissionsStart,
247
+ WORD_PAD,
248
+ skippedSentences,
249
+ chapterId
250
+ );
251
+ if (firstFoundSentence === null || firstConfirmedSentence === null) {
252
+ return {
253
+ score,
254
+ sentenceRanges: [],
255
+ wordRanges: [],
256
+ alignedEmissionsStart: emissionsStart,
257
+ alignedEmissionsEnd: emissionsStart,
258
+ firstFoundSentence: sentenceIndexOffset,
259
+ lastFoundSentence: sentenceIndexOffset
260
+ };
261
+ }
262
+ return {
263
+ score,
264
+ sentenceRanges,
265
+ wordRanges,
266
+ alignedEmissionsStart: alignments[sentenceStarts[firstConfirmedSentence][0]].startFrame + emissionsStart,
267
+ alignedEmissionsEnd: alignments[sentenceStarts[lastConfirmedSentence][1]].endFrame + emissionsStart,
268
+ firstFoundSentence: sentenceIndexOffset + firstFoundSentence,
269
+ lastFoundSentence: sentenceIndexOffset + lastFoundSentence
270
+ };
271
+ }
272
+ buildSentenceRanges(alignments, sentenceStarts, sentenceIndexOffset, emissionsStart, padFrames, chapterId, paddedChapterBoundaries, unpaddedChapterBoundaries) {
273
+ const sentenceRanges = [];
274
+ const skippedSentences = /* @__PURE__ */ new Set();
275
+ let firstFoundSentence = null;
276
+ let lastFoundSentence = 0;
277
+ let lastFoundSentenceEnd = 0;
278
+ let firstConfirmedSentence = null;
279
+ let lastConfirmedSentence = 0;
280
+ for (const [i, [startOffset, endOffset]] of enumerate(sentenceStarts)) {
281
+ if (endOffset < startOffset) {
282
+ skippedSentences.add(i);
283
+ continue;
284
+ }
285
+ if (endOffset < paddedChapterBoundaries[0] || startOffset >= paddedChapterBoundaries[1]) {
286
+ skippedSentences.add(i);
287
+ continue;
288
+ }
289
+ const goodChars = alignments.slice(startOffset, endOffset + 1).reduce((acc, a) => acc + (a.score > -4 ? 1 : 0), 0);
290
+ if (goodChars / (endOffset + 1 - startOffset) < 0.5) {
291
+ skippedSentences.add(i);
292
+ continue;
293
+ }
294
+ const sentenceLength = endOffset + 1 - startOffset;
295
+ const framesPerChar = (alignments[endOffset].endFrame - alignments[startOffset].startFrame) / sentenceLength;
296
+ if (sentenceLength > 8 && (framesPerChar < 2 || framesPerChar > 15)) {
297
+ skippedSentences.add(i);
298
+ continue;
299
+ }
300
+ const startFrame = this.refineStartFrame(
301
+ alignments[startOffset].startFrame,
302
+ lastFoundSentenceEnd,
303
+ padFrames,
304
+ emissionsStart
305
+ );
306
+ firstFoundSentence ??= i;
307
+ lastFoundSentence = i;
308
+ lastFoundSentenceEnd = alignments[endOffset].endFrame;
309
+ const { position: start, audiofile } = this.frameToAudioPosition(
310
+ startFrame,
311
+ emissionsStart
312
+ );
313
+ const { position: end } = this.frameToAudioPosition(
314
+ alignments[endOffset].endFrame,
315
+ emissionsStart
316
+ );
317
+ sentenceRanges.push({
318
+ id: sentenceIndexOffset + i,
319
+ chapterId,
320
+ start,
321
+ end,
322
+ audiofile
323
+ });
324
+ if (startOffset <= unpaddedChapterBoundaries[1] && endOffset >= unpaddedChapterBoundaries[0]) {
325
+ firstConfirmedSentence ??= i;
326
+ lastConfirmedSentence = i;
327
+ }
328
+ }
329
+ return {
330
+ sentenceRanges,
331
+ skippedSentences,
332
+ firstFoundSentence,
333
+ lastFoundSentence,
334
+ lastFoundSentenceEnd,
335
+ firstConfirmedSentence,
336
+ lastConfirmedSentence
337
+ };
338
+ }
339
+ buildWordRanges(alignments, wordStarts, sentenceIndexOffset, emissionsStart, padFrames, skippedSentences, chapterId) {
340
+ const wordRanges = [];
341
+ let lastFoundWordEnd = 0;
342
+ for (const [i, ws] of enumerate(wordStarts)) {
343
+ if (skippedSentences.has(i)) continue;
344
+ const ranges = [];
345
+ for (const [j, [startOffset, endOffset]] of enumerate(ws)) {
346
+ if (endOffset < startOffset) continue;
347
+ const goodChars = alignments.slice(startOffset, endOffset + 1).reduce((acc, a) => acc + (a.score > -4 ? 1 : 0), 0);
348
+ if (goodChars / (endOffset + 1 - startOffset) < 0.5) {
349
+ continue;
350
+ }
351
+ const startFrame = this.refineStartFrame(
352
+ alignments[startOffset].startFrame,
353
+ lastFoundWordEnd,
354
+ padFrames,
355
+ emissionsStart
356
+ );
357
+ lastFoundWordEnd = alignments[endOffset].endFrame;
358
+ const { position: start, audiofile } = this.frameToAudioPosition(
359
+ startFrame,
360
+ emissionsStart
361
+ );
362
+ const { position: end } = this.frameToAudioPosition(
363
+ alignments[endOffset].endFrame,
364
+ emissionsStart
365
+ );
366
+ ranges.push({
367
+ id: j,
368
+ sentenceId: sentenceIndexOffset + i,
369
+ chapterId,
370
+ start,
371
+ end,
372
+ audiofile
373
+ });
374
+ }
375
+ if (ranges.length) wordRanges.push(ranges);
376
+ }
377
+ return wordRanges;
378
+ }
379
+ frameToAudioPosition(frame, emissionsStart) {
380
+ const globalFrame = frame + emissionsStart;
381
+ for (const [audiofile, [fileStart, fileEnd]] of this.audiofileFrames) {
382
+ if (globalFrame < fileEnd) {
383
+ return {
384
+ position: framesToSeconds(
385
+ globalFrame - fileStart,
386
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
387
+ this.audiofileEmissionTimings.get(audiofile)
388
+ ),
389
+ audiofile
390
+ };
391
+ }
392
+ }
393
+ throw new Error(`Frame ${globalFrame} is beyond the end of the emissions`);
394
+ }
395
+ refineStartFrame(startFrame, prevEndFrame, padFrames, emissionsStart) {
396
+ const { audiofile } = this.frameToAudioPosition(startFrame, emissionsStart);
397
+ const [start] = this.audiofileFrames.get(audiofile);
398
+ const windowFloor = start - emissionsStart;
399
+ return Math.max(prevEndFrame + 1, startFrame - padFrames, windowFloor);
400
+ }
401
+ async alignChapter(spineIndex, chapterId, chunk, sentenceIndexOffset, chapterSentences, slugifiedChapter, sentenceStarts, wordStarts, emissionsStart, emissionsEnd, anchors, paddedChapterBoundaries, unpaddedChapterBoundaries) {
402
+ const timing = createTiming();
403
+ timing.start("read contents");
404
+ const manifest = await this.epub.getManifest();
405
+ const chapter = manifest[chapterId];
406
+ if (!chapter)
407
+ throw new Error(
408
+ `Failed to align chapter: could not find chapter with id ${chapterId} in manifest`
409
+ );
410
+ const chapterXml = await this.epub.readXhtmlItemContents(chapterId);
411
+ timing.start("align sentences");
412
+ const {
413
+ score,
414
+ sentenceRanges,
415
+ wordRanges,
416
+ alignedEmissionsStart,
417
+ alignedEmissionsEnd,
418
+ firstFoundSentence,
419
+ lastFoundSentence
420
+ } = await this.getSentenceRanges(
421
+ chapterId,
422
+ slugifiedChapter,
423
+ sentenceStarts,
424
+ wordStarts,
425
+ sentenceIndexOffset,
426
+ emissionsStart,
427
+ emissionsEnd,
428
+ anchors,
429
+ paddedChapterBoundaries,
430
+ unpaddedChapterBoundaries
431
+ );
432
+ timing.end("align sentences");
433
+ const storytellerStylesheetUrl = relative(
434
+ dirname(chapter.href),
435
+ "Styles/storyteller-readaloud.css"
436
+ );
437
+ Epub.addLinkToXhtmlHead(chapterXml, {
438
+ rel: "stylesheet",
439
+ href: storytellerStylesheetUrl,
440
+ type: "text/css"
441
+ });
442
+ this.alignedChapters.push({
443
+ chapter,
444
+ chunk,
445
+ xml: chapterXml,
446
+ sentenceRanges,
447
+ wordRanges,
448
+ startOffset: alignedEmissionsStart,
449
+ endOffset: alignedEmissionsEnd
450
+ });
451
+ this.addChapterReport(
452
+ spineIndex,
453
+ chapter,
454
+ chunk,
455
+ score,
456
+ chapterSentences,
457
+ sentenceStarts.length,
458
+ sentenceRanges,
459
+ firstFoundSentence,
460
+ lastFoundSentence,
461
+ alignedEmissionsStart,
462
+ alignedEmissionsEnd
463
+ );
464
+ return {
465
+ lastSentenceRange: sentenceRanges.at(-1) ?? null,
466
+ alignedEmissionsEnd,
467
+ timing
468
+ };
469
+ }
470
+ getMatchedBoundaries() {
471
+ return this.alignedChapters.toSorted((a, b) => a.startOffset - b.startOffset).map(({ startOffset, endOffset }) => [startOffset, endOffset]);
472
+ }
473
+ narrowToAvailableBoundary(boundary) {
474
+ const available = [
475
+ -1,
476
+ ...this.getMatchedBoundaries().flat(),
477
+ Infinity
478
+ ];
479
+ const withinBoundary = [];
480
+ for (let i = 0; i < available.length - 1; i += 2) {
481
+ const [start, end] = [available[i], available[i + 1]];
482
+ if (boundary.start <= start && boundary.end >= start || boundary.start <= end && boundary.end >= end) {
483
+ withinBoundary.push([
484
+ Math.max(boundary.start, start + 1),
485
+ Math.min(boundary.end, end - 1)
486
+ ]);
487
+ }
488
+ }
489
+ const largestBoundary = max(withinBoundary, ([start, end]) => end - start);
490
+ if (!largestBoundary) return { start: boundary.start, end: boundary.end };
491
+ return { start: largestBoundary[0], end: largestBoundary[1] };
492
+ }
493
+ async alignBook(onProgress) {
494
+ const { locale } = await this.prepareAlignment();
495
+ await this.processSpine(onProgress, locale);
496
+ await this.finalizeAlignment();
497
+ return this.timing;
498
+ }
499
+ async prepareAlignment() {
500
+ const locale = this.languageOverride ?? await this.epub.getLanguage() ?? new Intl.Locale("en-US");
501
+ this.timing.setMetadata("language", locale.toString());
502
+ this.timing.setMetadata("granularity", this.granularity);
503
+ return { locale };
504
+ }
505
+ async processSpine(onProgress, locale) {
506
+ const spine = await this.epub.getSpineItems();
507
+ const manifest = await this.epub.getManifest();
508
+ for (let index = 0; index < spine.length; index++) {
509
+ onProgress?.(index / spine.length);
510
+ const spineItem = spine[index];
511
+ this.logger?.info(
512
+ `Aligning epub item #${index} : ${basename(spineItem.href)}`
513
+ );
514
+ const results = await this.processSpineItem(
515
+ index,
516
+ spineItem,
517
+ manifest,
518
+ locale
519
+ );
520
+ results.forEach((result) => this.timing.add(result.timing.summary()));
521
+ }
522
+ }
523
+ async processSpineItem(index, spineItem, manifest, locale) {
524
+ const chapterId = spineItem.id;
525
+ if (manifest[chapterId]?.properties?.includes("nav")) {
526
+ this.report.unalignedChapters.push({
527
+ href: spineItem.href,
528
+ reason: "is-nav"
529
+ });
530
+ return [];
531
+ }
532
+ const chapterSentences = await this.getChapterSentences(chapterId);
533
+ const slugifiedChapterSentences = [];
534
+ const chapterMappings = [];
535
+ let slugifiedChapterLength = 0;
536
+ for (const chapterSentence of chapterSentences) {
537
+ const { result, mapping } = await slugify(
538
+ chapterSentence.text,
539
+ locale,
540
+ ""
541
+ );
542
+ slugifiedChapterSentences.push(result);
543
+ slugifiedChapterLength += result.length;
544
+ chapterMappings.push(mapping);
545
+ }
546
+ if (chapterSentences.length === 0 || slugifiedChapterLength === 0) {
547
+ this.logger?.info(`Chapter #${index} has no text; skipping`);
548
+ this.report.unalignedChapters.push({
549
+ href: spineItem.href,
550
+ reason: "no-text"
551
+ });
552
+ return [];
553
+ }
554
+ if (chapterSentences.length < 2 && // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
555
+ chapterSentences[0].words.length < 4) {
556
+ this.logger?.info(`Chapter #${index} is fewer than four words; skipping`);
557
+ this.report.unalignedChapters.push({
558
+ href: spineItem.href,
559
+ reason: "too-short"
560
+ });
561
+ return [];
562
+ }
563
+ const boundaries = await findCtcBoundaries(
564
+ slugifiedChapterSentences.join(""),
565
+ this.emissionsReader,
566
+ this.getMatchedBoundaries()
567
+ );
568
+ const evidenceRange = boundaries?.evidenceRange;
569
+ const matchedRange = boundaries?.matchedRange;
570
+ const chunkCount = Math.ceil(slugifiedChapterLength / MAX_SEARCH_LENGTH);
571
+ const chunkLength = Math.ceil(slugifiedChapterLength / chunkCount);
572
+ const chunks = [
573
+ { firstSentence: 0, sentences: [] }
574
+ ];
575
+ let c = 0;
576
+ for (const [i, s] of enumerate(slugifiedChapterSentences)) {
577
+ if (c >= chunkLength * chunks.length && // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
578
+ chunks.at(-1).sentences.length > 0) {
579
+ chunks.push({ firstSentence: i, sentences: [] });
580
+ }
581
+ chunks.at(-1).sentences.push(s);
582
+ c += s.length;
583
+ }
584
+ if (chunks.length > 1) {
585
+ this.logger?.info(
586
+ `Chapter #${index} is ${slugifiedChapterLength} chars; aligning in ${chunks.length} chunks`
587
+ );
588
+ }
589
+ const results = [];
590
+ const firstAlignedChapterIndex = this.alignedChapters.length;
591
+ for (const [
592
+ chunkIndex,
593
+ { firstSentence, sentences: chunkSentences }
594
+ ] of enumerate(chunks)) {
595
+ let pos = 0;
596
+ const slugifiedChapterRanges = [];
597
+ for (const sent of chunkSentences) {
598
+ slugifiedChapterRanges.push([pos, pos + sent.length - 1]);
599
+ pos += sent.length;
600
+ }
601
+ const slugifiedWordStarts = chunkSentences.map((_, i) => {
602
+ const sent = chapterSentences[firstSentence + i];
603
+ const cursor = chapterMappings[firstSentence + i].cursor();
604
+ return sent.words.entries.filter((word) => !word.isPunctuation).map((word) => [
605
+ cursor.map(word.startOffset - sent.charRange.start) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
606
+ slugifiedChapterRanges[i][0],
607
+ cursor.map(word.endOffset - 1 - sent.charRange.start) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
608
+ slugifiedChapterRanges[i][0]
609
+ ]);
610
+ });
611
+ const chunkString = chunkSentences.join("");
612
+ const boundaries2 = await findCtcBoundaries(
613
+ chunkString,
614
+ this.emissionsReader,
615
+ this.getMatchedBoundaries()
616
+ );
617
+ if (!boundaries2) {
618
+ this.logger?.info(
619
+ `Could not find chapter #${index} in the transcripton`
620
+ );
621
+ this.report.unalignedChapters.push({
622
+ href: spineItem.href,
623
+ reason: "not-found",
624
+ spineIndex: index,
625
+ score: 0,
626
+ chapterSentenceCount: chunkSentences.length,
627
+ start: chapterSentences.slice(firstSentence, firstSentence + 3).map((s) => s.text).join("").slice(0, 80),
628
+ end: chapterSentences.slice(
629
+ Math.max(
630
+ firstSentence,
631
+ firstSentence + chunkSentences.length - 3
632
+ ),
633
+ firstSentence + chunkSentences.length
634
+ ).map((s) => s.text).join("").slice(-80)
635
+ });
636
+ continue;
637
+ }
638
+ const { start, end } = this.narrowToAvailableBoundary(boundaries2);
639
+ if (start === end) {
640
+ this.logger?.info(
641
+ `Could not find chapter #${index} in the transcripton`
642
+ );
643
+ this.report.unalignedChapters.push({
644
+ href: spineItem.href,
645
+ reason: "not-found",
646
+ spineIndex: index,
647
+ score: 0,
648
+ chapterSentenceCount: chunkSentences.length,
649
+ start: chapterSentences.slice(firstSentence, firstSentence + 3).map((s) => s.text).join("").slice(0, 80),
650
+ end: chapterSentences.slice(
651
+ Math.max(
652
+ firstSentence,
653
+ firstSentence + chunkSentences.length - 3
654
+ ),
655
+ firstSentence + chunkSentences.length
656
+ ).map((s) => s.text).join("").slice(-80)
657
+ });
658
+ continue;
659
+ }
660
+ const lastChunkSlugStart = slugifiedChapterLength - chunkString.length;
661
+ const chunkEvidenceHead = chunkIndex === 0 ? evidenceRange?.[0] ?? boundaries2.evidenceRange[0] : boundaries2.evidenceRange[0];
662
+ const chunkEvidenceTail = chunkIndex === chunks.length - 1 && evidenceRange ? evidenceRange[1] - lastChunkSlugStart : boundaries2.evidenceRange[1];
663
+ const chunkMatchedHead = chunkIndex === 0 ? matchedRange?.[0] ?? boundaries2.matchedRange[0] : boundaries2.matchedRange[0];
664
+ const chunkMatchedTail = chunkIndex === chunks.length - 1 && matchedRange ? matchedRange[1] - lastChunkSlugStart : boundaries2.matchedRange[1];
665
+ const result = await this.alignChapter(
666
+ index,
667
+ chapterId,
668
+ chunkIndex,
669
+ firstSentence,
670
+ chapterSentences,
671
+ chunkString,
672
+ slugifiedChapterRanges,
673
+ slugifiedWordStarts,
674
+ start,
675
+ end,
676
+ boundaries2.anchors,
677
+ [chunkEvidenceHead, chunkEvidenceTail],
678
+ [chunkMatchedHead, chunkMatchedTail]
679
+ );
680
+ results.push(result);
681
+ }
682
+ const chunkEntries = this.alignedChapters.slice(firstAlignedChapterIndex);
683
+ if (chunkEntries.length > 1) {
684
+ const claimed = chunkEntries.filter((e) => e.sentenceRanges.length > 0);
685
+ const offsetSources = claimed.length > 0 ? claimed : chunkEntries;
686
+ const first = chunkEntries[0];
687
+ this.alignedChapters.splice(
688
+ firstAlignedChapterIndex,
689
+ chunkEntries.length,
690
+ {
691
+ chapter: first.chapter,
692
+ chunk: 0,
693
+ xml: first.xml,
694
+ sentenceRanges: chunkEntries.flatMap((e) => e.sentenceRanges),
695
+ wordRanges: chunkEntries.flatMap((e) => e.wordRanges),
696
+ startOffset: Math.min(...offsetSources.map((e) => e.startOffset)),
697
+ endOffset: Math.max(...offsetSources.map((e) => e.endOffset))
698
+ }
699
+ );
700
+ }
701
+ return results;
702
+ }
703
+ async finalizeAlignment() {
704
+ const audioOrderedChapters = this.alignedChapters.toSorted((a, b) => {
705
+ const firstRangeA = a.sentenceRanges[0];
706
+ const firstRangeB = b.sentenceRanges[0];
707
+ if (!firstRangeA) return 1;
708
+ if (!firstRangeB) return -1;
709
+ const firstAudiofileIndexA = this.audiofiles.indexOf(
710
+ firstRangeA.audiofile
711
+ );
712
+ const firstAudiofileIndexB = this.audiofiles.indexOf(
713
+ firstRangeB.audiofile
714
+ );
715
+ if (firstAudiofileIndexA === firstAudiofileIndexB) {
716
+ return firstRangeA.start - firstRangeB.start;
717
+ }
718
+ return firstAudiofileIndexA - firstAudiofileIndexB;
719
+ });
720
+ const sentenceRanges = [];
721
+ const chapterSentenceCounts = {};
722
+ const sentenceLengths = {};
723
+ for (const alignedChapter of audioOrderedChapters) {
724
+ sentenceRanges.push(...alignedChapter.sentenceRanges);
725
+ const sentences = await this.getChapterSentences(
726
+ alignedChapter.chapter.id
727
+ );
728
+ chapterSentenceCounts[alignedChapter.chapter.id] = sentences.length;
729
+ for (const [id, sentence] of enumerate(sentences)) {
730
+ sentenceLengths[slotKey({ chapterId: alignedChapter.chapter.id, id })] = sentence.text.length;
731
+ }
732
+ }
733
+ const interpolated = interpolateSentenceRanges(
734
+ sentenceRanges,
735
+ chapterSentenceCounts,
736
+ this.audioFileDurations,
737
+ sentenceLengths
738
+ );
739
+ const expanded = expandEmptySentenceRanges(interpolated);
740
+ const collapsed = await collapseSentenceRangeGaps(expanded);
741
+ let collapsedStart = 0;
742
+ for (const alignedChapter of audioOrderedChapters) {
743
+ const sentences = await this.getChapterSentences(
744
+ alignedChapter.chapter.id
745
+ );
746
+ const finalSentenceRanges = collapsed.slice(
747
+ collapsedStart,
748
+ collapsedStart + sentences.length
749
+ );
750
+ alignedChapter.sentenceRanges = finalSentenceRanges;
751
+ for (const [i, wordRanges] of enumerate(alignedChapter.wordRanges)) {
752
+ alignedChapter.wordRanges[i] = expandEmptySentenceRanges(wordRanges);
753
+ }
754
+ await this.writeAlignedChapter(alignedChapter);
755
+ collapsedStart += sentences.length;
756
+ }
757
+ for (const audiofile of this.audiofiles) {
758
+ if (!this.report.audioFiles.some(({ filepath }) => filepath === audiofile)) {
759
+ this.report.unalignedAudioFiles.push({ filepath: audiofile });
760
+ }
761
+ }
762
+ await this.epub.addMetadata({
763
+ type: "meta",
764
+ properties: { property: "media:duration" },
765
+ value: Epub.formatSmilDuration(this.totalDuration)
766
+ });
767
+ await this.epub.addMetadata({
768
+ type: "meta",
769
+ properties: { property: "media:active-class" },
770
+ value: "-epub-media-overlay-active"
771
+ });
772
+ await this.epub.addManifestItem(
773
+ {
774
+ id: "storyteller_readaloud_styles",
775
+ href: "Styles/storyteller-readaloud.css",
776
+ mediaType: "text/css"
777
+ },
778
+ `
779
+ .-epub-media-overlay-active {
780
+ background-color: #ffb;
781
+ }
782
+ `,
783
+ "utf-8"
784
+ );
785
+ }
786
+ }
787
+ export {
788
+ Aligner
789
+ };