@storyteller-platform/align 0.1.19 → 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,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,
@@ -357,6 +369,7 @@ class Aligner {
357
369
  this.transcription.transcript,
358
370
  locale
359
371
  );
372
+ const mappedTimeline = (0, import_getSentenceRanges.mapTranscriptionTimeline)(this.transcription, mapping);
360
373
  for (let index = 0; index < spine.length; index++) {
361
374
  onProgress?.(index / spine.length);
362
375
  const spineItem = spine[index];
@@ -371,7 +384,7 @@ class Aligner {
371
384
  const slugifiedChapterSentences = [];
372
385
  for (const chapterSentence of chapterSentences) {
373
386
  slugifiedChapterSentences.push(
374
- (await (0, import_slugify.slugify)(chapterSentence, locale)).result
387
+ (await (0, import_slugify.slugify)(chapterSentence.text, locale)).result
375
388
  );
376
389
  }
377
390
  if (chapterSentences.length === 0) {
@@ -379,7 +392,7 @@ class Aligner {
379
392
  continue;
380
393
  }
381
394
  if (chapterSentences.length < 2 && // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
382
- chapterSentences[0].split(" ").length < 4) {
395
+ chapterSentences[0].words.length < 4) {
383
396
  this.logger?.info(
384
397
  `Chapter #${index} is fewer than four words; skipping`
385
398
  );
@@ -399,14 +412,13 @@ class Aligner {
399
412
  if (start === end) {
400
413
  continue;
401
414
  }
402
- const transcriptionOffset = mapping.invert().map(Math.max(start, 0), -1);
403
- const endOffset = mapping.invert().map(Math.min(end, transcriptionText.length), 1);
404
415
  const result = await this.alignChapter(
405
416
  chapterId,
406
- transcriptionOffset,
407
- endOffset,
417
+ transcriptionText,
418
+ Math.max(start, 0),
419
+ Math.min(end, transcriptionText.length),
408
420
  locale,
409
- mapping
421
+ mappedTimeline
410
422
  );
411
423
  this.timing.add(result.timing.summary());
412
424
  }
@@ -426,16 +438,36 @@ class Aligner {
426
438
  }
427
439
  return firstAudiofileIndexA - firstAudiofileIndexB;
428
440
  });
429
- let lastSentenceRange = null;
441
+ const sentenceRanges = [];
442
+ const chapterSentenceCounts = {};
430
443
  for (const alignedChapter of audioOrderedChapters) {
431
- const interpolated = await (0, import_getSentenceRanges.interpolateSentenceRanges)(
432
- alignedChapter.sentenceRanges,
433
- lastSentenceRange
444
+ sentenceRanges.push(...alignedChapter.sentenceRanges);
445
+ const sentences = await this.getChapterSentences(
446
+ alignedChapter.chapter.id
434
447
  );
435
- const expanded = (0, import_getSentenceRanges.expandEmptySentenceRanges)(interpolated);
436
- alignedChapter.sentenceRanges = expanded;
437
- 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
+ }
438
469
  await this.writeAlignedChapter(alignedChapter);
470
+ collapsedStart += sentences.length - 1;
439
471
  }
440
472
  await this.epub.addMetadata({
441
473
  type: "meta",
@@ -463,7 +495,7 @@ class Aligner {
463
495
  return this.timing;
464
496
  }
465
497
  }
466
- function createMediaOverlay(chapter, sentenceRanges) {
498
+ function createMediaOverlay(chapter, granularity, sentenceRanges, wordRanges) {
467
499
  return [
468
500
  import_epub.Epub.createXmlElement(
469
501
  "smil",
@@ -481,24 +513,53 @@ function createMediaOverlay(chapter, sentenceRanges) {
481
513
  "epub:textref": `../${chapter.href}`,
482
514
  "epub:type": "chapter"
483
515
  },
484
- sentenceRanges.map(
485
- (sentenceRange) => import_epub.Epub.createXmlElement(
486
- "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",
487
538
  {
488
- 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}`
489
542
  },
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
- )
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
+ })
502
563
  )
503
564
  ])
504
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;
@@ -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 } 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,
@@ -291,6 +305,7 @@ class Aligner {
291
305
  this.transcription.transcript,
292
306
  locale
293
307
  );
308
+ const mappedTimeline = mapTranscriptionTimeline(this.transcription, mapping);
294
309
  for (let index = 0; index < spine.length; index++) {
295
310
  onProgress?.(index / spine.length);
296
311
  const spineItem = spine[index];
@@ -305,7 +320,7 @@ class Aligner {
305
320
  const slugifiedChapterSentences = [];
306
321
  for (const chapterSentence of chapterSentences) {
307
322
  slugifiedChapterSentences.push(
308
- (await slugify(chapterSentence, locale)).result
323
+ (await slugify(chapterSentence.text, locale)).result
309
324
  );
310
325
  }
311
326
  if (chapterSentences.length === 0) {
@@ -313,7 +328,7 @@ class Aligner {
313
328
  continue;
314
329
  }
315
330
  if (chapterSentences.length < 2 && // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
316
- chapterSentences[0].split(" ").length < 4) {
331
+ chapterSentences[0].words.length < 4) {
317
332
  this.logger?.info(
318
333
  `Chapter #${index} is fewer than four words; skipping`
319
334
  );
@@ -333,14 +348,13 @@ class Aligner {
333
348
  if (start === end) {
334
349
  continue;
335
350
  }
336
- const transcriptionOffset = mapping.invert().map(Math.max(start, 0), -1);
337
- const endOffset = mapping.invert().map(Math.min(end, transcriptionText.length), 1);
338
351
  const result = await this.alignChapter(
339
352
  chapterId,
340
- transcriptionOffset,
341
- endOffset,
353
+ transcriptionText,
354
+ Math.max(start, 0),
355
+ Math.min(end, transcriptionText.length),
342
356
  locale,
343
- mapping
357
+ mappedTimeline
344
358
  );
345
359
  this.timing.add(result.timing.summary());
346
360
  }
@@ -360,16 +374,36 @@ class Aligner {
360
374
  }
361
375
  return firstAudiofileIndexA - firstAudiofileIndexB;
362
376
  });
363
- let lastSentenceRange = null;
377
+ const sentenceRanges = [];
378
+ const chapterSentenceCounts = {};
364
379
  for (const alignedChapter of audioOrderedChapters) {
365
- const interpolated = await interpolateSentenceRanges(
366
- alignedChapter.sentenceRanges,
367
- lastSentenceRange
380
+ sentenceRanges.push(...alignedChapter.sentenceRanges);
381
+ const sentences = await this.getChapterSentences(
382
+ alignedChapter.chapter.id
368
383
  );
369
- const expanded = expandEmptySentenceRanges(interpolated);
370
- alignedChapter.sentenceRanges = expanded;
371
- 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
+ }
372
405
  await this.writeAlignedChapter(alignedChapter);
406
+ collapsedStart += sentences.length - 1;
373
407
  }
374
408
  await this.epub.addMetadata({
375
409
  type: "meta",
@@ -397,7 +431,7 @@ class Aligner {
397
431
  return this.timing;
398
432
  }
399
433
  }
400
- function createMediaOverlay(chapter, sentenceRanges) {
434
+ function createMediaOverlay(chapter, granularity, sentenceRanges, wordRanges) {
401
435
  return [
402
436
  Epub.createXmlElement(
403
437
  "smil",
@@ -415,24 +449,53 @@ function createMediaOverlay(chapter, sentenceRanges) {
415
449
  "epub:textref": `../${chapter.href}`,
416
450
  "epub:type": "chapter"
417
451
  },
418
- sentenceRanges.map(
419
- (sentenceRange) => Epub.createXmlElement(
420
- "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",
421
474
  {
422
- 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}`
423
478
  },
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
- )
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
+ })
436
499
  )
437
500
  ])
438
501
  ]