@storyteller-platform/align 0.1.18 → 0.1.20

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,19 +253,20 @@ 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
- chapterSentenceCount: sentenceRanges.length,
268
+ chapterSentenceCount: chapterSentences.length,
269
+ alignedSentenceCount: sentenceRanges.length,
262
270
  audioFiles: sentenceRanges.reduce((acc, range) => {
263
271
  const existing = acc.find(
264
272
  (context) => context.filepath === range.audiofile
@@ -276,7 +284,7 @@ class Aligner {
276
284
  }, [])
277
285
  });
278
286
  }
279
- async alignChapter(chapterId, transcriptionOffset, transcriptionEndOffset, locale, mapping) {
287
+ async alignChapter(chapterId, transcriptionText, transcriptionOffset, transcriptionEndOffset, locale, mappedTimeline) {
280
288
  const timing = (0, import_ghost_story.createTiming)();
281
289
  timing.start("read contents");
282
290
  const manifest = await this.epub.getManifest();
@@ -293,14 +301,18 @@ class Aligner {
293
301
  timing.start("align sentences");
294
302
  const {
295
303
  sentenceRanges,
304
+ wordRanges,
296
305
  transcriptionOffset: endTranscriptionOffset,
297
306
  firstFoundSentence,
298
307
  lastFoundSentence
299
308
  } = await (0, import_getSentenceRanges.getSentenceRanges)(
300
- this.transcription,
309
+ transcriptionText,
310
+ mappedTimeline,
301
311
  chapterSentences,
312
+ chapterId,
302
313
  transcriptionOffset,
303
314
  transcriptionEndOffset,
315
+ this.granularity,
304
316
  locale
305
317
  );
306
318
  timing.end("align sentences");
@@ -317,8 +329,9 @@ class Aligner {
317
329
  chapter,
318
330
  xml: chapterXml,
319
331
  sentenceRanges,
320
- startOffset: mapping.map(transcriptionOffset),
321
- endOffset: mapping.map(endTranscriptionOffset, -1)
332
+ wordRanges,
333
+ startOffset: transcriptionOffset,
334
+ endOffset: endTranscriptionOffset
322
335
  });
323
336
  this.addChapterReport(
324
337
  chapter,
@@ -356,6 +369,7 @@ class Aligner {
356
369
  this.transcription.transcript,
357
370
  locale
358
371
  );
372
+ const mappedTimeline = (0, import_getSentenceRanges.mapTranscriptionTimeline)(this.transcription, mapping);
359
373
  for (let index = 0; index < spine.length; index++) {
360
374
  onProgress?.(index / spine.length);
361
375
  const spineItem = spine[index];
@@ -370,7 +384,7 @@ class Aligner {
370
384
  const slugifiedChapterSentences = [];
371
385
  for (const chapterSentence of chapterSentences) {
372
386
  slugifiedChapterSentences.push(
373
- (await (0, import_slugify.slugify)(chapterSentence, locale)).result
387
+ (await (0, import_slugify.slugify)(chapterSentence.text, locale)).result
374
388
  );
375
389
  }
376
390
  if (chapterSentences.length === 0) {
@@ -378,7 +392,7 @@ class Aligner {
378
392
  continue;
379
393
  }
380
394
  if (chapterSentences.length < 2 && // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
381
- chapterSentences[0].split(" ").length < 4) {
395
+ chapterSentences[0].words.length < 4) {
382
396
  this.logger?.info(
383
397
  `Chapter #${index} is fewer than four words; skipping`
384
398
  );
@@ -398,14 +412,13 @@ class Aligner {
398
412
  if (start === end) {
399
413
  continue;
400
414
  }
401
- const transcriptionOffset = mapping.invert().map(Math.max(start, 0), -1);
402
- const endOffset = mapping.invert().map(Math.min(end, transcriptionText.length), 1);
403
415
  const result = await this.alignChapter(
404
416
  chapterId,
405
- transcriptionOffset,
406
- endOffset,
417
+ transcriptionText,
418
+ Math.max(start, 0),
419
+ Math.min(end, transcriptionText.length),
407
420
  locale,
408
- mapping
421
+ mappedTimeline
409
422
  );
410
423
  this.timing.add(result.timing.summary());
411
424
  }
@@ -425,16 +438,36 @@ class Aligner {
425
438
  }
426
439
  return firstAudiofileIndexA - firstAudiofileIndexB;
427
440
  });
428
- let lastSentenceRange = null;
441
+ const sentenceRanges = [];
442
+ const chapterSentenceCounts = {};
429
443
  for (const alignedChapter of audioOrderedChapters) {
430
- const interpolated = await (0, import_getSentenceRanges.interpolateSentenceRanges)(
431
- alignedChapter.sentenceRanges,
432
- lastSentenceRange
444
+ sentenceRanges.push(...alignedChapter.sentenceRanges);
445
+ const sentences = await this.getChapterSentences(
446
+ alignedChapter.chapter.id
433
447
  );
434
- const expanded = (0, import_getSentenceRanges.expandEmptySentenceRanges)(interpolated);
435
- alignedChapter.sentenceRanges = expanded;
436
- lastSentenceRange = expanded.at(-1) ?? null;
448
+ chapterSentenceCounts[alignedChapter.chapter.id] = sentences.length;
449
+ }
450
+ const interpolated = await (0, import_getSentenceRanges.interpolateSentenceRanges)(
451
+ sentenceRanges,
452
+ chapterSentenceCounts
453
+ );
454
+ const expanded = (0, import_getSentenceRanges.expandEmptySentenceRanges)(interpolated);
455
+ const collapsed = await (0, import_getSentenceRanges.collapseSentenceRangeGaps)(expanded);
456
+ let collapsedStart = 0;
457
+ for (const alignedChapter of audioOrderedChapters) {
458
+ const sentences = await this.getChapterSentences(
459
+ alignedChapter.chapter.id
460
+ );
461
+ const finalSentenceRanges = collapsed.slice(
462
+ collapsedStart,
463
+ collapsedStart + sentences.length - 1
464
+ );
465
+ alignedChapter.sentenceRanges = finalSentenceRanges;
466
+ for (const [i, wordRanges] of (0, import_itertools.enumerate)(alignedChapter.wordRanges)) {
467
+ alignedChapter.wordRanges[i] = (0, import_getSentenceRanges.expandEmptySentenceRanges)(wordRanges);
468
+ }
437
469
  await this.writeAlignedChapter(alignedChapter);
470
+ collapsedStart += sentences.length - 1;
438
471
  }
439
472
  await this.epub.addMetadata({
440
473
  type: "meta",
@@ -462,7 +495,7 @@ class Aligner {
462
495
  return this.timing;
463
496
  }
464
497
  }
465
- function createMediaOverlay(chapter, sentenceRanges) {
498
+ function createMediaOverlay(chapter, granularity, sentenceRanges, wordRanges) {
466
499
  return [
467
500
  import_epub.Epub.createXmlElement(
468
501
  "smil",
@@ -480,24 +513,53 @@ function createMediaOverlay(chapter, sentenceRanges) {
480
513
  "epub:textref": `../${chapter.href}`,
481
514
  "epub:type": "chapter"
482
515
  },
483
- sentenceRanges.map(
484
- (sentenceRange) => import_epub.Epub.createXmlElement(
485
- "par",
516
+ sentenceRanges.map((sentenceRange) => {
517
+ if (granularity === "sentence" || !wordRanges.has(sentenceRange.id)) {
518
+ return import_epub.Epub.createXmlElement(
519
+ "par",
520
+ {
521
+ id: `${chapter.id}-s${sentenceRange.id}`
522
+ },
523
+ [
524
+ import_epub.Epub.createXmlElement("text", {
525
+ src: `../${chapter.href}#${chapter.id}-s${sentenceRange.id}`
526
+ }),
527
+ import_epub.Epub.createXmlElement("audio", {
528
+ src: `../Audio/${(0, import_posix.basename)(sentenceRange.audiofile)}`,
529
+ clipBegin: `${sentenceRange.start.toFixed(3)}s`,
530
+ clipEnd: `${sentenceRange.end.toFixed(3)}s`
531
+ })
532
+ ]
533
+ );
534
+ }
535
+ const words = wordRanges.get(sentenceRange.id);
536
+ return import_epub.Epub.createXmlElement(
537
+ "seq",
486
538
  {
487
- id: `${chapter.id}-s${sentenceRange.id}`
539
+ id: `${chapter.id}-s${sentenceRange.id}`,
540
+ "epub:type": "text-range-small",
541
+ "epub:textref": `../${chapter.href}#${chapter.id}-s${sentenceRange.id}`
488
542
  },
489
- [
490
- import_epub.Epub.createXmlElement("text", {
491
- src: `../${chapter.href}#${chapter.id}-s${sentenceRange.id}`
492
- }),
493
- import_epub.Epub.createXmlElement("audio", {
494
- src: `../Audio/${(0, import_posix.basename)(sentenceRange.audiofile)}`,
495
- clipBegin: `${sentenceRange.start.toFixed(3)}s`,
496
- clipEnd: `${sentenceRange.end.toFixed(3)}s`
497
- })
498
- ]
499
- )
500
- )
543
+ words.map(
544
+ (word) => import_epub.Epub.createXmlElement(
545
+ "par",
546
+ {
547
+ id: `${chapter.id}-s${sentenceRange.id}-w${word.id}`
548
+ },
549
+ [
550
+ import_epub.Epub.createXmlElement("text", {
551
+ src: `../${chapter.href}#${chapter.id}-s${sentenceRange.id}-w${word.id}`
552
+ }),
553
+ import_epub.Epub.createXmlElement("audio", {
554
+ src: `../Audio/${(0, import_posix.basename)(word.audiofile)}`,
555
+ clipBegin: `${word.start.toFixed(3)}s`,
556
+ clipEnd: `${word.end.toFixed(3)}s`
557
+ })
558
+ ]
559
+ )
560
+ )
561
+ );
562
+ })
501
563
  )
502
564
  ])
503
565
  ]
@@ -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;
@@ -29,6 +31,7 @@ interface ChapterReport {
29
31
  nextSentence: string | null;
30
32
  };
31
33
  chapterSentenceCount: number;
34
+ alignedSentenceCount: number;
32
35
  audioFiles: AudioFileContext[];
33
36
  }
34
37
  interface Report {
@@ -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;
@@ -29,6 +31,7 @@ interface ChapterReport {
29
31
  nextSentence: string | null;
30
32
  };
31
33
  chapterSentenceCount: number;
34
+ alignedSentenceCount: number;
32
35
  audioFiles: AudioFileContext[];
33
36
  }
34
37
  interface Report {
@@ -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 } 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,19 +189,20 @@ 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
- chapterSentenceCount: sentenceRanges.length,
204
+ chapterSentenceCount: chapterSentences.length,
205
+ alignedSentenceCount: sentenceRanges.length,
196
206
  audioFiles: sentenceRanges.reduce((acc, range) => {
197
207
  const existing = acc.find(
198
208
  (context) => context.filepath === range.audiofile
@@ -210,7 +220,7 @@ class Aligner {
210
220
  }, [])
211
221
  });
212
222
  }
213
- async alignChapter(chapterId, transcriptionOffset, transcriptionEndOffset, locale, mapping) {
223
+ async alignChapter(chapterId, transcriptionText, transcriptionOffset, transcriptionEndOffset, locale, mappedTimeline) {
214
224
  const timing = createTiming();
215
225
  timing.start("read contents");
216
226
  const manifest = await this.epub.getManifest();
@@ -227,14 +237,18 @@ class Aligner {
227
237
  timing.start("align sentences");
228
238
  const {
229
239
  sentenceRanges,
240
+ wordRanges,
230
241
  transcriptionOffset: endTranscriptionOffset,
231
242
  firstFoundSentence,
232
243
  lastFoundSentence
233
244
  } = await getSentenceRanges(
234
- this.transcription,
245
+ transcriptionText,
246
+ mappedTimeline,
235
247
  chapterSentences,
248
+ chapterId,
236
249
  transcriptionOffset,
237
250
  transcriptionEndOffset,
251
+ this.granularity,
238
252
  locale
239
253
  );
240
254
  timing.end("align sentences");
@@ -251,8 +265,9 @@ class Aligner {
251
265
  chapter,
252
266
  xml: chapterXml,
253
267
  sentenceRanges,
254
- startOffset: mapping.map(transcriptionOffset),
255
- endOffset: mapping.map(endTranscriptionOffset, -1)
268
+ wordRanges,
269
+ startOffset: transcriptionOffset,
270
+ endOffset: endTranscriptionOffset
256
271
  });
257
272
  this.addChapterReport(
258
273
  chapter,
@@ -290,6 +305,7 @@ class Aligner {
290
305
  this.transcription.transcript,
291
306
  locale
292
307
  );
308
+ const mappedTimeline = mapTranscriptionTimeline(this.transcription, mapping);
293
309
  for (let index = 0; index < spine.length; index++) {
294
310
  onProgress?.(index / spine.length);
295
311
  const spineItem = spine[index];
@@ -304,7 +320,7 @@ class Aligner {
304
320
  const slugifiedChapterSentences = [];
305
321
  for (const chapterSentence of chapterSentences) {
306
322
  slugifiedChapterSentences.push(
307
- (await slugify(chapterSentence, locale)).result
323
+ (await slugify(chapterSentence.text, locale)).result
308
324
  );
309
325
  }
310
326
  if (chapterSentences.length === 0) {
@@ -312,7 +328,7 @@ class Aligner {
312
328
  continue;
313
329
  }
314
330
  if (chapterSentences.length < 2 && // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
315
- chapterSentences[0].split(" ").length < 4) {
331
+ chapterSentences[0].words.length < 4) {
316
332
  this.logger?.info(
317
333
  `Chapter #${index} is fewer than four words; skipping`
318
334
  );
@@ -332,14 +348,13 @@ class Aligner {
332
348
  if (start === end) {
333
349
  continue;
334
350
  }
335
- const transcriptionOffset = mapping.invert().map(Math.max(start, 0), -1);
336
- const endOffset = mapping.invert().map(Math.min(end, transcriptionText.length), 1);
337
351
  const result = await this.alignChapter(
338
352
  chapterId,
339
- transcriptionOffset,
340
- endOffset,
353
+ transcriptionText,
354
+ Math.max(start, 0),
355
+ Math.min(end, transcriptionText.length),
341
356
  locale,
342
- mapping
357
+ mappedTimeline
343
358
  );
344
359
  this.timing.add(result.timing.summary());
345
360
  }
@@ -359,16 +374,36 @@ class Aligner {
359
374
  }
360
375
  return firstAudiofileIndexA - firstAudiofileIndexB;
361
376
  });
362
- let lastSentenceRange = null;
377
+ const sentenceRanges = [];
378
+ const chapterSentenceCounts = {};
363
379
  for (const alignedChapter of audioOrderedChapters) {
364
- const interpolated = await interpolateSentenceRanges(
365
- alignedChapter.sentenceRanges,
366
- lastSentenceRange
380
+ sentenceRanges.push(...alignedChapter.sentenceRanges);
381
+ const sentences = await this.getChapterSentences(
382
+ alignedChapter.chapter.id
367
383
  );
368
- const expanded = expandEmptySentenceRanges(interpolated);
369
- alignedChapter.sentenceRanges = expanded;
370
- lastSentenceRange = expanded.at(-1) ?? null;
384
+ chapterSentenceCounts[alignedChapter.chapter.id] = sentences.length;
385
+ }
386
+ const interpolated = await interpolateSentenceRanges(
387
+ sentenceRanges,
388
+ chapterSentenceCounts
389
+ );
390
+ const expanded = expandEmptySentenceRanges(interpolated);
391
+ const collapsed = await collapseSentenceRangeGaps(expanded);
392
+ let collapsedStart = 0;
393
+ for (const alignedChapter of audioOrderedChapters) {
394
+ const sentences = await this.getChapterSentences(
395
+ alignedChapter.chapter.id
396
+ );
397
+ const finalSentenceRanges = collapsed.slice(
398
+ collapsedStart,
399
+ collapsedStart + sentences.length - 1
400
+ );
401
+ alignedChapter.sentenceRanges = finalSentenceRanges;
402
+ for (const [i, wordRanges] of enumerate(alignedChapter.wordRanges)) {
403
+ alignedChapter.wordRanges[i] = expandEmptySentenceRanges(wordRanges);
404
+ }
371
405
  await this.writeAlignedChapter(alignedChapter);
406
+ collapsedStart += sentences.length - 1;
372
407
  }
373
408
  await this.epub.addMetadata({
374
409
  type: "meta",
@@ -396,7 +431,7 @@ class Aligner {
396
431
  return this.timing;
397
432
  }
398
433
  }
399
- function createMediaOverlay(chapter, sentenceRanges) {
434
+ function createMediaOverlay(chapter, granularity, sentenceRanges, wordRanges) {
400
435
  return [
401
436
  Epub.createXmlElement(
402
437
  "smil",
@@ -414,24 +449,53 @@ function createMediaOverlay(chapter, sentenceRanges) {
414
449
  "epub:textref": `../${chapter.href}`,
415
450
  "epub:type": "chapter"
416
451
  },
417
- sentenceRanges.map(
418
- (sentenceRange) => Epub.createXmlElement(
419
- "par",
452
+ sentenceRanges.map((sentenceRange) => {
453
+ if (granularity === "sentence" || !wordRanges.has(sentenceRange.id)) {
454
+ return Epub.createXmlElement(
455
+ "par",
456
+ {
457
+ id: `${chapter.id}-s${sentenceRange.id}`
458
+ },
459
+ [
460
+ Epub.createXmlElement("text", {
461
+ src: `../${chapter.href}#${chapter.id}-s${sentenceRange.id}`
462
+ }),
463
+ Epub.createXmlElement("audio", {
464
+ src: `../Audio/${basename(sentenceRange.audiofile)}`,
465
+ clipBegin: `${sentenceRange.start.toFixed(3)}s`,
466
+ clipEnd: `${sentenceRange.end.toFixed(3)}s`
467
+ })
468
+ ]
469
+ );
470
+ }
471
+ const words = wordRanges.get(sentenceRange.id);
472
+ return Epub.createXmlElement(
473
+ "seq",
420
474
  {
421
- id: `${chapter.id}-s${sentenceRange.id}`
475
+ id: `${chapter.id}-s${sentenceRange.id}`,
476
+ "epub:type": "text-range-small",
477
+ "epub:textref": `../${chapter.href}#${chapter.id}-s${sentenceRange.id}`
422
478
  },
423
- [
424
- Epub.createXmlElement("text", {
425
- src: `../${chapter.href}#${chapter.id}-s${sentenceRange.id}`
426
- }),
427
- Epub.createXmlElement("audio", {
428
- src: `../Audio/${basename(sentenceRange.audiofile)}`,
429
- clipBegin: `${sentenceRange.start.toFixed(3)}s`,
430
- clipEnd: `${sentenceRange.end.toFixed(3)}s`
431
- })
432
- ]
433
- )
434
- )
479
+ words.map(
480
+ (word) => Epub.createXmlElement(
481
+ "par",
482
+ {
483
+ id: `${chapter.id}-s${sentenceRange.id}-w${word.id}`
484
+ },
485
+ [
486
+ Epub.createXmlElement("text", {
487
+ src: `../${chapter.href}#${chapter.id}-s${sentenceRange.id}-w${word.id}`
488
+ }),
489
+ Epub.createXmlElement("audio", {
490
+ src: `../Audio/${basename(word.audiofile)}`,
491
+ clipBegin: `${word.start.toFixed(3)}s`,
492
+ clipEnd: `${word.end.toFixed(3)}s`
493
+ })
494
+ ]
495
+ )
496
+ )
497
+ );
498
+ })
435
499
  )
436
500
  ])
437
501
  ]