@storyteller-platform/align 0.1.19 → 0.1.21

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.
@@ -81,6 +81,7 @@ module.exports = __toCommonJS(align_exports);
81
81
  var import_promises = require("node:fs/promises");
82
82
  var import_node_path = require("node:path");
83
83
  var import_posix = require("node:path/posix");
84
+ var import_itertools = require("itertools");
84
85
  var import_memoize = __toESM(require("memoize"), 1);
85
86
  var import_audiobook = require("@storyteller-platform/audiobook");
86
87
  var import_epub = require("@storyteller-platform/epub");
@@ -173,10 +174,11 @@ class Aligner {
173
174
  primaryLocale: this.languageOverride ?? await this.epub.getLanguage()
174
175
  }
175
176
  );
176
- return segmentation.map((s) => s.text).filter((s) => s.match(/\S/));
177
+ return segmentation.filter((s) => s.text.match(/\S/));
177
178
  }
178
179
  async writeAlignedChapter(alignedChapter) {
179
- const { chapter, sentenceRanges, xml } = alignedChapter;
180
+ const { chapter, sentenceRanges, wordRanges, xml } = alignedChapter;
181
+ const wordRangeMap = new Map(wordRanges.map((w) => [w[0].sentenceId, w]));
180
182
  const audiofiles = Array.from(
181
183
  new Set(sentenceRanges.map(({ audiofile }) => audiofile))
182
184
  );
@@ -209,7 +211,12 @@ class Aligner {
209
211
  href: `MediaOverlays/${chapterStem}.smil`,
210
212
  mediaType: "application/smil+xml"
211
213
  },
212
- createMediaOverlay(chapter, sentenceRanges),
214
+ createMediaOverlay(
215
+ chapter,
216
+ this.granularity,
217
+ sentenceRanges,
218
+ wordRangeMap
219
+ ),
213
220
  "xml"
214
221
  );
215
222
  await this.epub.updateManifestItem(chapter.id, {
@@ -246,17 +253,17 @@ class Aligner {
246
253
  },
247
254
  firstMatchedSentenceId: startSentence,
248
255
  firstMatchedSentenceContext: {
249
- prevSentence: chapterSentences[startSentence - 1] ?? null,
256
+ prevSentence: chapterSentences[startSentence - 1]?.text ?? null,
250
257
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
251
- matchedSentence: chapterSentences[startSentence],
252
- nextSentence: chapterSentences[startSentence + 1] ?? null
258
+ matchedSentence: chapterSentences[startSentence].text,
259
+ nextSentence: chapterSentences[startSentence + 1]?.text ?? null
253
260
  },
254
261
  lastMatchedSentenceId: endSentence,
255
262
  lastMatchedSentenceContext: {
256
- prevSentence: chapterSentences[endSentence - 1] ?? null,
263
+ prevSentence: chapterSentences[endSentence - 1]?.text ?? null,
257
264
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
258
- matchedSentence: chapterSentences[endSentence],
259
- nextSentence: chapterSentences[endSentence + 1] ?? null
265
+ matchedSentence: chapterSentences[endSentence].text,
266
+ nextSentence: chapterSentences[endSentence + 1]?.text ?? null
260
267
  },
261
268
  chapterSentenceCount: chapterSentences.length,
262
269
  alignedSentenceCount: sentenceRanges.length,
@@ -277,7 +284,7 @@ class Aligner {
277
284
  }, [])
278
285
  });
279
286
  }
280
- async alignChapter(chapterId, transcriptionOffset, transcriptionEndOffset, locale, mapping) {
287
+ async alignChapter(chapterId, transcriptionText, transcriptionOffset, transcriptionEndOffset, locale, mappedTimeline) {
281
288
  const timing = (0, import_ghost_story.createTiming)();
282
289
  timing.start("read contents");
283
290
  const manifest = await this.epub.getManifest();
@@ -294,14 +301,18 @@ class Aligner {
294
301
  timing.start("align sentences");
295
302
  const {
296
303
  sentenceRanges,
304
+ wordRanges,
297
305
  transcriptionOffset: endTranscriptionOffset,
298
306
  firstFoundSentence,
299
307
  lastFoundSentence
300
308
  } = await (0, import_getSentenceRanges.getSentenceRanges)(
301
- this.transcription,
309
+ transcriptionText,
310
+ mappedTimeline,
302
311
  chapterSentences,
312
+ chapterId,
303
313
  transcriptionOffset,
304
314
  transcriptionEndOffset,
315
+ this.granularity,
305
316
  locale
306
317
  );
307
318
  timing.end("align sentences");
@@ -318,8 +329,9 @@ class Aligner {
318
329
  chapter,
319
330
  xml: chapterXml,
320
331
  sentenceRanges,
321
- startOffset: mapping.map(transcriptionOffset),
322
- endOffset: mapping.map(endTranscriptionOffset, -1)
332
+ wordRanges,
333
+ startOffset: transcriptionOffset,
334
+ endOffset: endTranscriptionOffset
323
335
  });
324
336
  this.addChapterReport(
325
337
  chapter,
@@ -336,16 +348,24 @@ class Aligner {
336
348
  };
337
349
  }
338
350
  narrowToAvailableBoundary(boundary) {
339
- const narrowed = { ...boundary };
340
- for (const chapter of this.alignedChapters) {
341
- if (chapter.startOffset > narrowed.start && chapter.startOffset <= narrowed.end) {
342
- narrowed.end = chapter.startOffset - 1;
343
- }
344
- if (chapter.endOffset < narrowed.end && chapter.endOffset >= narrowed.start) {
345
- narrowed.start = chapter.endOffset + 1;
351
+ const available = [
352
+ -1,
353
+ ...this.alignedChapters.toSorted((a, b) => a.startOffset - b.startOffset).flatMap(({ startOffset, endOffset }) => [startOffset, endOffset]),
354
+ Infinity
355
+ ];
356
+ const withinBoundary = [];
357
+ for (let i = 0; i < available.length - 1; i += 2) {
358
+ const [start, end] = [available[i], available[i + 1]];
359
+ if (boundary.start <= start && boundary.end >= start || boundary.start <= end && boundary.end >= end) {
360
+ withinBoundary.push([
361
+ Math.max(boundary.start, start + 1),
362
+ Math.min(boundary.end, end - 1)
363
+ ]);
346
364
  }
347
365
  }
348
- return narrowed;
366
+ const largestBoundary = (0, import_itertools.max)(withinBoundary, ([start, end]) => end - start);
367
+ if (!largestBoundary) return { start: boundary.start, end: boundary.end };
368
+ return { start: largestBoundary[0], end: largestBoundary[1] };
349
369
  }
350
370
  async alignBook(onProgress) {
351
371
  const locale = this.languageOverride ?? await this.epub.getLanguage() ?? new Intl.Locale("en-US");
@@ -357,6 +377,7 @@ class Aligner {
357
377
  this.transcription.transcript,
358
378
  locale
359
379
  );
380
+ const mappedTimeline = (0, import_getSentenceRanges.mapTranscriptionTimeline)(this.transcription, mapping);
360
381
  for (let index = 0; index < spine.length; index++) {
361
382
  onProgress?.(index / spine.length);
362
383
  const spineItem = spine[index];
@@ -371,7 +392,7 @@ class Aligner {
371
392
  const slugifiedChapterSentences = [];
372
393
  for (const chapterSentence of chapterSentences) {
373
394
  slugifiedChapterSentences.push(
374
- (await (0, import_slugify.slugify)(chapterSentence, locale)).result
395
+ (await (0, import_slugify.slugify)(chapterSentence.text, locale)).result
375
396
  );
376
397
  }
377
398
  if (chapterSentences.length === 0) {
@@ -379,7 +400,7 @@ class Aligner {
379
400
  continue;
380
401
  }
381
402
  if (chapterSentences.length < 2 && // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
382
- chapterSentences[0].split(" ").length < 4) {
403
+ chapterSentences[0].words.length < 4) {
383
404
  this.logger?.info(
384
405
  `Chapter #${index} is fewer than four words; skipping`
385
406
  );
@@ -399,14 +420,13 @@ class Aligner {
399
420
  if (start === end) {
400
421
  continue;
401
422
  }
402
- const transcriptionOffset = mapping.invert().map(Math.max(start, 0), -1);
403
- const endOffset = mapping.invert().map(Math.min(end, transcriptionText.length), 1);
404
423
  const result = await this.alignChapter(
405
424
  chapterId,
406
- transcriptionOffset,
407
- endOffset,
425
+ transcriptionText,
426
+ Math.max(start, 0),
427
+ Math.min(end, transcriptionText.length),
408
428
  locale,
409
- mapping
429
+ mappedTimeline
410
430
  );
411
431
  this.timing.add(result.timing.summary());
412
432
  }
@@ -426,16 +446,36 @@ class Aligner {
426
446
  }
427
447
  return firstAudiofileIndexA - firstAudiofileIndexB;
428
448
  });
429
- let lastSentenceRange = null;
449
+ const sentenceRanges = [];
450
+ const chapterSentenceCounts = {};
451
+ for (const alignedChapter of audioOrderedChapters) {
452
+ sentenceRanges.push(...alignedChapter.sentenceRanges);
453
+ const sentences = await this.getChapterSentences(
454
+ alignedChapter.chapter.id
455
+ );
456
+ chapterSentenceCounts[alignedChapter.chapter.id] = sentences.length;
457
+ }
458
+ const interpolated = await (0, import_getSentenceRanges.interpolateSentenceRanges)(
459
+ sentenceRanges,
460
+ chapterSentenceCounts
461
+ );
462
+ const expanded = (0, import_getSentenceRanges.expandEmptySentenceRanges)(interpolated);
463
+ const collapsed = await (0, import_getSentenceRanges.collapseSentenceRangeGaps)(expanded);
464
+ let collapsedStart = 0;
430
465
  for (const alignedChapter of audioOrderedChapters) {
431
- const interpolated = await (0, import_getSentenceRanges.interpolateSentenceRanges)(
432
- alignedChapter.sentenceRanges,
433
- lastSentenceRange
466
+ const sentences = await this.getChapterSentences(
467
+ alignedChapter.chapter.id
434
468
  );
435
- const expanded = (0, import_getSentenceRanges.expandEmptySentenceRanges)(interpolated);
436
- alignedChapter.sentenceRanges = expanded;
437
- lastSentenceRange = expanded.at(-1) ?? null;
469
+ const finalSentenceRanges = collapsed.slice(
470
+ collapsedStart,
471
+ collapsedStart + sentences.length - 1
472
+ );
473
+ alignedChapter.sentenceRanges = finalSentenceRanges;
474
+ for (const [i, wordRanges] of (0, import_itertools.enumerate)(alignedChapter.wordRanges)) {
475
+ alignedChapter.wordRanges[i] = (0, import_getSentenceRanges.expandEmptySentenceRanges)(wordRanges);
476
+ }
438
477
  await this.writeAlignedChapter(alignedChapter);
478
+ collapsedStart += sentences.length - 1;
439
479
  }
440
480
  await this.epub.addMetadata({
441
481
  type: "meta",
@@ -463,7 +503,7 @@ class Aligner {
463
503
  return this.timing;
464
504
  }
465
505
  }
466
- function createMediaOverlay(chapter, sentenceRanges) {
506
+ function createMediaOverlay(chapter, granularity, sentenceRanges, wordRanges) {
467
507
  return [
468
508
  import_epub.Epub.createXmlElement(
469
509
  "smil",
@@ -481,24 +521,53 @@ function createMediaOverlay(chapter, sentenceRanges) {
481
521
  "epub:textref": `../${chapter.href}`,
482
522
  "epub:type": "chapter"
483
523
  },
484
- sentenceRanges.map(
485
- (sentenceRange) => import_epub.Epub.createXmlElement(
486
- "par",
524
+ sentenceRanges.map((sentenceRange) => {
525
+ if (granularity === "sentence" || !wordRanges.has(sentenceRange.id)) {
526
+ return import_epub.Epub.createXmlElement(
527
+ "par",
528
+ {
529
+ id: `${chapter.id}-s${sentenceRange.id}`
530
+ },
531
+ [
532
+ import_epub.Epub.createXmlElement("text", {
533
+ src: `../${chapter.href}#${chapter.id}-s${sentenceRange.id}`
534
+ }),
535
+ import_epub.Epub.createXmlElement("audio", {
536
+ src: `../Audio/${(0, import_posix.basename)(sentenceRange.audiofile)}`,
537
+ clipBegin: `${sentenceRange.start.toFixed(3)}s`,
538
+ clipEnd: `${sentenceRange.end.toFixed(3)}s`
539
+ })
540
+ ]
541
+ );
542
+ }
543
+ const words = wordRanges.get(sentenceRange.id);
544
+ return import_epub.Epub.createXmlElement(
545
+ "seq",
487
546
  {
488
- id: `${chapter.id}-s${sentenceRange.id}`
547
+ id: `${chapter.id}-s${sentenceRange.id}`,
548
+ "epub:type": "text-range-small",
549
+ "epub:textref": `../${chapter.href}#${chapter.id}-s${sentenceRange.id}`
489
550
  },
490
- [
491
- import_epub.Epub.createXmlElement("text", {
492
- src: `../${chapter.href}#${chapter.id}-s${sentenceRange.id}`
493
- }),
494
- import_epub.Epub.createXmlElement("audio", {
495
- src: `../Audio/${(0, import_posix.basename)(sentenceRange.audiofile)}`,
496
- clipBegin: `${sentenceRange.start.toFixed(3)}s`,
497
- clipEnd: `${sentenceRange.end.toFixed(3)}s`
498
- })
499
- ]
500
- )
501
- )
551
+ words.map(
552
+ (word) => import_epub.Epub.createXmlElement(
553
+ "par",
554
+ {
555
+ id: `${chapter.id}-s${sentenceRange.id}-w${word.id}`
556
+ },
557
+ [
558
+ import_epub.Epub.createXmlElement("text", {
559
+ src: `../${chapter.href}#${chapter.id}-s${sentenceRange.id}-w${word.id}`
560
+ }),
561
+ import_epub.Epub.createXmlElement("audio", {
562
+ src: `../Audio/${(0, import_posix.basename)(word.audiofile)}`,
563
+ clipBegin: `${word.start.toFixed(3)}s`,
564
+ clipEnd: `${word.end.toFixed(3)}s`
565
+ })
566
+ ]
567
+ )
568
+ )
569
+ );
570
+ })
502
571
  )
503
572
  ])
504
573
  ]
@@ -3,6 +3,8 @@ import { Logger } from 'pino';
3
3
  import { Epub } from '@storyteller-platform/epub';
4
4
  import { RecognitionResult } from '@storyteller-platform/ghost-story/recognition';
5
5
  import { StorytellerTranscription } from './getSentenceRanges.cjs';
6
+ import '@echogarden/text-segmentation';
7
+ import '@storyteller-platform/transliteration';
6
8
 
7
9
  interface AudioFileContext {
8
10
  start: number;
@@ -3,6 +3,8 @@ import { Logger } from 'pino';
3
3
  import { Epub } from '@storyteller-platform/epub';
4
4
  import { RecognitionResult } from '@storyteller-platform/ghost-story/recognition';
5
5
  import { StorytellerTranscription } from './getSentenceRanges.js';
6
+ import '@echogarden/text-segmentation';
7
+ import '@storyteller-platform/transliteration';
6
8
 
7
9
  interface AudioFileContext {
8
10
  start: number;
@@ -5,6 +5,7 @@ import {
5
5
  import { copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
6
6
  import { dirname as autoDirname, join as autoJoin } from "node:path";
7
7
  import { basename, dirname, parse, relative } from "node:path/posix";
8
+ import { enumerate, max } from "itertools";
8
9
  import memoize from "memoize";
9
10
  import { isAudioFile, lookupAudioMime } from "@storyteller-platform/audiobook";
10
11
  import {
@@ -17,10 +18,12 @@ import {
17
18
  import { getTrackDuration } from "../common/ffmpeg.js";
18
19
  import { getXhtmlSegmentation } from "../markup/segmentation.js";
19
20
  import {
21
+ collapseSentenceRangeGaps,
20
22
  expandEmptySentenceRanges,
21
23
  getChapterDuration,
22
24
  getSentenceRanges,
23
- interpolateSentenceRanges
25
+ interpolateSentenceRanges,
26
+ mapTranscriptionTimeline
24
27
  } from "./getSentenceRanges.js";
25
28
  import { findBoundaries } from "./search.js";
26
29
  import { slugify } from "./slugify.js";
@@ -107,10 +110,11 @@ class Aligner {
107
110
  primaryLocale: this.languageOverride ?? await this.epub.getLanguage()
108
111
  }
109
112
  );
110
- return segmentation.map((s) => s.text).filter((s) => s.match(/\S/));
113
+ return segmentation.filter((s) => s.text.match(/\S/));
111
114
  }
112
115
  async writeAlignedChapter(alignedChapter) {
113
- const { chapter, sentenceRanges, xml } = alignedChapter;
116
+ const { chapter, sentenceRanges, wordRanges, xml } = alignedChapter;
117
+ const wordRangeMap = new Map(wordRanges.map((w) => [w[0].sentenceId, w]));
114
118
  const audiofiles = Array.from(
115
119
  new Set(sentenceRanges.map(({ audiofile }) => audiofile))
116
120
  );
@@ -143,7 +147,12 @@ class Aligner {
143
147
  href: `MediaOverlays/${chapterStem}.smil`,
144
148
  mediaType: "application/smil+xml"
145
149
  },
146
- createMediaOverlay(chapter, sentenceRanges),
150
+ createMediaOverlay(
151
+ chapter,
152
+ this.granularity,
153
+ sentenceRanges,
154
+ wordRangeMap
155
+ ),
147
156
  "xml"
148
157
  );
149
158
  await this.epub.updateManifestItem(chapter.id, {
@@ -180,17 +189,17 @@ class Aligner {
180
189
  },
181
190
  firstMatchedSentenceId: startSentence,
182
191
  firstMatchedSentenceContext: {
183
- prevSentence: chapterSentences[startSentence - 1] ?? null,
192
+ prevSentence: chapterSentences[startSentence - 1]?.text ?? null,
184
193
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
185
- matchedSentence: chapterSentences[startSentence],
186
- nextSentence: chapterSentences[startSentence + 1] ?? null
194
+ matchedSentence: chapterSentences[startSentence].text,
195
+ nextSentence: chapterSentences[startSentence + 1]?.text ?? null
187
196
  },
188
197
  lastMatchedSentenceId: endSentence,
189
198
  lastMatchedSentenceContext: {
190
- prevSentence: chapterSentences[endSentence - 1] ?? null,
199
+ prevSentence: chapterSentences[endSentence - 1]?.text ?? null,
191
200
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
192
- matchedSentence: chapterSentences[endSentence],
193
- nextSentence: chapterSentences[endSentence + 1] ?? null
201
+ matchedSentence: chapterSentences[endSentence].text,
202
+ nextSentence: chapterSentences[endSentence + 1]?.text ?? null
194
203
  },
195
204
  chapterSentenceCount: chapterSentences.length,
196
205
  alignedSentenceCount: sentenceRanges.length,
@@ -211,7 +220,7 @@ class Aligner {
211
220
  }, [])
212
221
  });
213
222
  }
214
- async alignChapter(chapterId, transcriptionOffset, transcriptionEndOffset, locale, mapping) {
223
+ async alignChapter(chapterId, transcriptionText, transcriptionOffset, transcriptionEndOffset, locale, mappedTimeline) {
215
224
  const timing = createTiming();
216
225
  timing.start("read contents");
217
226
  const manifest = await this.epub.getManifest();
@@ -228,14 +237,18 @@ class Aligner {
228
237
  timing.start("align sentences");
229
238
  const {
230
239
  sentenceRanges,
240
+ wordRanges,
231
241
  transcriptionOffset: endTranscriptionOffset,
232
242
  firstFoundSentence,
233
243
  lastFoundSentence
234
244
  } = await getSentenceRanges(
235
- this.transcription,
245
+ transcriptionText,
246
+ mappedTimeline,
236
247
  chapterSentences,
248
+ chapterId,
237
249
  transcriptionOffset,
238
250
  transcriptionEndOffset,
251
+ this.granularity,
239
252
  locale
240
253
  );
241
254
  timing.end("align sentences");
@@ -252,8 +265,9 @@ class Aligner {
252
265
  chapter,
253
266
  xml: chapterXml,
254
267
  sentenceRanges,
255
- startOffset: mapping.map(transcriptionOffset),
256
- endOffset: mapping.map(endTranscriptionOffset, -1)
268
+ wordRanges,
269
+ startOffset: transcriptionOffset,
270
+ endOffset: endTranscriptionOffset
257
271
  });
258
272
  this.addChapterReport(
259
273
  chapter,
@@ -270,16 +284,24 @@ class Aligner {
270
284
  };
271
285
  }
272
286
  narrowToAvailableBoundary(boundary) {
273
- const narrowed = { ...boundary };
274
- for (const chapter of this.alignedChapters) {
275
- if (chapter.startOffset > narrowed.start && chapter.startOffset <= narrowed.end) {
276
- narrowed.end = chapter.startOffset - 1;
277
- }
278
- if (chapter.endOffset < narrowed.end && chapter.endOffset >= narrowed.start) {
279
- narrowed.start = chapter.endOffset + 1;
287
+ const available = [
288
+ -1,
289
+ ...this.alignedChapters.toSorted((a, b) => a.startOffset - b.startOffset).flatMap(({ startOffset, endOffset }) => [startOffset, endOffset]),
290
+ Infinity
291
+ ];
292
+ const withinBoundary = [];
293
+ for (let i = 0; i < available.length - 1; i += 2) {
294
+ const [start, end] = [available[i], available[i + 1]];
295
+ if (boundary.start <= start && boundary.end >= start || boundary.start <= end && boundary.end >= end) {
296
+ withinBoundary.push([
297
+ Math.max(boundary.start, start + 1),
298
+ Math.min(boundary.end, end - 1)
299
+ ]);
280
300
  }
281
301
  }
282
- return narrowed;
302
+ const largestBoundary = max(withinBoundary, ([start, end]) => end - start);
303
+ if (!largestBoundary) return { start: boundary.start, end: boundary.end };
304
+ return { start: largestBoundary[0], end: largestBoundary[1] };
283
305
  }
284
306
  async alignBook(onProgress) {
285
307
  const locale = this.languageOverride ?? await this.epub.getLanguage() ?? new Intl.Locale("en-US");
@@ -291,6 +313,7 @@ class Aligner {
291
313
  this.transcription.transcript,
292
314
  locale
293
315
  );
316
+ const mappedTimeline = mapTranscriptionTimeline(this.transcription, mapping);
294
317
  for (let index = 0; index < spine.length; index++) {
295
318
  onProgress?.(index / spine.length);
296
319
  const spineItem = spine[index];
@@ -305,7 +328,7 @@ class Aligner {
305
328
  const slugifiedChapterSentences = [];
306
329
  for (const chapterSentence of chapterSentences) {
307
330
  slugifiedChapterSentences.push(
308
- (await slugify(chapterSentence, locale)).result
331
+ (await slugify(chapterSentence.text, locale)).result
309
332
  );
310
333
  }
311
334
  if (chapterSentences.length === 0) {
@@ -313,7 +336,7 @@ class Aligner {
313
336
  continue;
314
337
  }
315
338
  if (chapterSentences.length < 2 && // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
316
- chapterSentences[0].split(" ").length < 4) {
339
+ chapterSentences[0].words.length < 4) {
317
340
  this.logger?.info(
318
341
  `Chapter #${index} is fewer than four words; skipping`
319
342
  );
@@ -333,14 +356,13 @@ class Aligner {
333
356
  if (start === end) {
334
357
  continue;
335
358
  }
336
- const transcriptionOffset = mapping.invert().map(Math.max(start, 0), -1);
337
- const endOffset = mapping.invert().map(Math.min(end, transcriptionText.length), 1);
338
359
  const result = await this.alignChapter(
339
360
  chapterId,
340
- transcriptionOffset,
341
- endOffset,
361
+ transcriptionText,
362
+ Math.max(start, 0),
363
+ Math.min(end, transcriptionText.length),
342
364
  locale,
343
- mapping
365
+ mappedTimeline
344
366
  );
345
367
  this.timing.add(result.timing.summary());
346
368
  }
@@ -360,16 +382,36 @@ class Aligner {
360
382
  }
361
383
  return firstAudiofileIndexA - firstAudiofileIndexB;
362
384
  });
363
- let lastSentenceRange = null;
385
+ const sentenceRanges = [];
386
+ const chapterSentenceCounts = {};
387
+ for (const alignedChapter of audioOrderedChapters) {
388
+ sentenceRanges.push(...alignedChapter.sentenceRanges);
389
+ const sentences = await this.getChapterSentences(
390
+ alignedChapter.chapter.id
391
+ );
392
+ chapterSentenceCounts[alignedChapter.chapter.id] = sentences.length;
393
+ }
394
+ const interpolated = await interpolateSentenceRanges(
395
+ sentenceRanges,
396
+ chapterSentenceCounts
397
+ );
398
+ const expanded = expandEmptySentenceRanges(interpolated);
399
+ const collapsed = await collapseSentenceRangeGaps(expanded);
400
+ let collapsedStart = 0;
364
401
  for (const alignedChapter of audioOrderedChapters) {
365
- const interpolated = await interpolateSentenceRanges(
366
- alignedChapter.sentenceRanges,
367
- lastSentenceRange
402
+ const sentences = await this.getChapterSentences(
403
+ alignedChapter.chapter.id
368
404
  );
369
- const expanded = expandEmptySentenceRanges(interpolated);
370
- alignedChapter.sentenceRanges = expanded;
371
- lastSentenceRange = expanded.at(-1) ?? null;
405
+ const finalSentenceRanges = collapsed.slice(
406
+ collapsedStart,
407
+ collapsedStart + sentences.length - 1
408
+ );
409
+ alignedChapter.sentenceRanges = finalSentenceRanges;
410
+ for (const [i, wordRanges] of enumerate(alignedChapter.wordRanges)) {
411
+ alignedChapter.wordRanges[i] = expandEmptySentenceRanges(wordRanges);
412
+ }
372
413
  await this.writeAlignedChapter(alignedChapter);
414
+ collapsedStart += sentences.length - 1;
373
415
  }
374
416
  await this.epub.addMetadata({
375
417
  type: "meta",
@@ -397,7 +439,7 @@ class Aligner {
397
439
  return this.timing;
398
440
  }
399
441
  }
400
- function createMediaOverlay(chapter, sentenceRanges) {
442
+ function createMediaOverlay(chapter, granularity, sentenceRanges, wordRanges) {
401
443
  return [
402
444
  Epub.createXmlElement(
403
445
  "smil",
@@ -415,24 +457,53 @@ function createMediaOverlay(chapter, sentenceRanges) {
415
457
  "epub:textref": `../${chapter.href}`,
416
458
  "epub:type": "chapter"
417
459
  },
418
- sentenceRanges.map(
419
- (sentenceRange) => Epub.createXmlElement(
420
- "par",
460
+ sentenceRanges.map((sentenceRange) => {
461
+ if (granularity === "sentence" || !wordRanges.has(sentenceRange.id)) {
462
+ return Epub.createXmlElement(
463
+ "par",
464
+ {
465
+ id: `${chapter.id}-s${sentenceRange.id}`
466
+ },
467
+ [
468
+ Epub.createXmlElement("text", {
469
+ src: `../${chapter.href}#${chapter.id}-s${sentenceRange.id}`
470
+ }),
471
+ Epub.createXmlElement("audio", {
472
+ src: `../Audio/${basename(sentenceRange.audiofile)}`,
473
+ clipBegin: `${sentenceRange.start.toFixed(3)}s`,
474
+ clipEnd: `${sentenceRange.end.toFixed(3)}s`
475
+ })
476
+ ]
477
+ );
478
+ }
479
+ const words = wordRanges.get(sentenceRange.id);
480
+ return Epub.createXmlElement(
481
+ "seq",
421
482
  {
422
- id: `${chapter.id}-s${sentenceRange.id}`
483
+ id: `${chapter.id}-s${sentenceRange.id}`,
484
+ "epub:type": "text-range-small",
485
+ "epub:textref": `../${chapter.href}#${chapter.id}-s${sentenceRange.id}`
423
486
  },
424
- [
425
- Epub.createXmlElement("text", {
426
- src: `../${chapter.href}#${chapter.id}-s${sentenceRange.id}`
427
- }),
428
- Epub.createXmlElement("audio", {
429
- src: `../Audio/${basename(sentenceRange.audiofile)}`,
430
- clipBegin: `${sentenceRange.start.toFixed(3)}s`,
431
- clipEnd: `${sentenceRange.end.toFixed(3)}s`
432
- })
433
- ]
434
- )
435
- )
487
+ words.map(
488
+ (word) => Epub.createXmlElement(
489
+ "par",
490
+ {
491
+ id: `${chapter.id}-s${sentenceRange.id}-w${word.id}`
492
+ },
493
+ [
494
+ Epub.createXmlElement("text", {
495
+ src: `../${chapter.href}#${chapter.id}-s${sentenceRange.id}-w${word.id}`
496
+ }),
497
+ Epub.createXmlElement("audio", {
498
+ src: `../Audio/${basename(word.audiofile)}`,
499
+ clipBegin: `${word.start.toFixed(3)}s`,
500
+ clipEnd: `${word.end.toFixed(3)}s`
501
+ })
502
+ ]
503
+ )
504
+ )
505
+ );
506
+ })
436
507
  )
437
508
  ])
438
509
  ]