reaowl 0.0.3__py3-none-any.whl

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.
reaowl/__init__.py ADDED
@@ -0,0 +1,1937 @@
1
+ from kokoro import KPipeline
2
+
3
+ from IPython.display import Audio, display, HTML
4
+
5
+ import soundfile as sf
6
+ import numpy as np
7
+
8
+ import re
9
+ import os
10
+ import json
11
+ import html
12
+ import base64
13
+
14
+
15
+ class T2S:
16
+
17
+ # =========================================================
18
+ # INITIALIZE
19
+ # =========================================================
20
+
21
+ def __init__(
22
+ self,
23
+ text='',
24
+ filename='',
25
+ speed=1.0,
26
+ voice="af_nicole",
27
+ lang_code="a"
28
+ ):
29
+
30
+
31
+
32
+ if text == '':
33
+ self.original_text = self.extract_text(filepath=filename)
34
+ else:
35
+ self.original_text = text
36
+
37
+
38
+
39
+ self.speed = speed
40
+ self.voice = voice
41
+ self.lang_code = lang_code
42
+
43
+ # Create Kokoro pipeline
44
+ self.pipeline = KPipeline(
45
+ lang_code=self.lang_code
46
+ )
47
+
48
+ # Clean and align text
49
+ self.text = self.align()
50
+
51
+ # Sentence structure used by BOTH readers
52
+ self.sentences = self.make_sentences()
53
+
54
+ self.file = None
55
+ self.timings_file = None
56
+ self.word_timings = []
57
+
58
+
59
+ # =========================================================
60
+ # ALIGN TEXT
61
+ # =========================================================
62
+
63
+ def align(self):
64
+
65
+ text = self.original_text
66
+
67
+ # Windows / Linux line endings
68
+ text = text.replace("\r\n", "\n")
69
+ text = text.replace("\r", "\n")
70
+
71
+ # Join words broken by PDF hyphenation
72
+ text = re.sub(
73
+ r"(\w+)-\s*\n\s*(\w+)",
74
+ r"\1\2",
75
+ text
76
+ )
77
+
78
+ # Separate paragraphs
79
+ paragraphs = re.split(
80
+ r"\n\s*\n+",
81
+ text
82
+ )
83
+
84
+ cleaned_paragraphs = []
85
+
86
+ for paragraph in paragraphs:
87
+
88
+ # Convert line breaks inside paragraph to spaces
89
+ paragraph = re.sub(
90
+ r"\s*\n\s*",
91
+ " ",
92
+ paragraph
93
+ )
94
+
95
+ # Remove repeated spaces
96
+ paragraph = re.sub(
97
+ r"[ \t]+",
98
+ " ",
99
+ paragraph
100
+ )
101
+
102
+ paragraph = paragraph.strip()
103
+
104
+ if paragraph:
105
+ cleaned_paragraphs.append(
106
+ paragraph
107
+ )
108
+
109
+ # Split sentences
110
+ output_parts = []
111
+
112
+ for paragraph in cleaned_paragraphs:
113
+
114
+ sentences = re.split(
115
+ r"(?<=[.!?])\s+",
116
+ paragraph
117
+ )
118
+
119
+ sentences = [
120
+ sentence.strip()
121
+ for sentence in sentences
122
+ if sentence.strip()
123
+ ]
124
+
125
+ output_parts.append(
126
+ "\n".join(sentences)
127
+ )
128
+
129
+ output_text = "\n\n".join(
130
+ output_parts
131
+ )
132
+
133
+ print("\n==============================")
134
+ print("ALIGNED TEXT")
135
+ print("==============================\n")
136
+ print(output_text)
137
+
138
+ return output_text
139
+
140
+
141
+ # =========================================================
142
+ # MAKE SENTENCES
143
+ # =========================================================
144
+
145
+ def make_sentences(self):
146
+
147
+ paragraphs = self.text.split("\n\n")
148
+
149
+ sentences = []
150
+
151
+ for paragraph in paragraphs:
152
+
153
+ lines = paragraph.split("\n")
154
+
155
+ for line in lines:
156
+
157
+ line = line.strip()
158
+
159
+ if not line:
160
+ continue
161
+
162
+ # Make actual words
163
+ words = re.findall(
164
+ r"\S+",
165
+ line
166
+ )
167
+
168
+ sentences.append({
169
+ "text": line,
170
+ "words": words
171
+ })
172
+
173
+ return sentences
174
+
175
+
176
+ # =========================================================
177
+ # EXTRACT TOKEN TEXT
178
+ # =========================================================
179
+
180
+ def _token_text(self, token):
181
+
182
+ # Dictionary
183
+ if isinstance(token, dict):
184
+
185
+ for key in [
186
+ "text",
187
+ "token",
188
+ "word"
189
+ ]:
190
+
191
+ if key in token:
192
+ return str(
193
+ token[key]
194
+ )
195
+
196
+ # Object
197
+ for attr in [
198
+ "text",
199
+ "token",
200
+ "word"
201
+ ]:
202
+
203
+ if hasattr(token, attr):
204
+
205
+ value = getattr(
206
+ token,
207
+ attr
208
+ )
209
+
210
+ if value is not None:
211
+ return str(value)
212
+
213
+ # Fallback
214
+ return str(token)
215
+
216
+
217
+ # =========================================================
218
+ # EXTRACT TOKEN START
219
+ # =========================================================
220
+
221
+ def _token_start(self, token):
222
+
223
+ if isinstance(token, dict):
224
+
225
+ for key in [
226
+ "start",
227
+ "start_ts",
228
+ "start_time"
229
+ ]:
230
+
231
+ if key in token:
232
+ return float(
233
+ token[key]
234
+ )
235
+
236
+ for attr in [
237
+ "start",
238
+ "start_ts",
239
+ "start_time"
240
+ ]:
241
+
242
+ if hasattr(token, attr):
243
+
244
+ value = getattr(
245
+ token,
246
+ attr
247
+ )
248
+
249
+ if value is not None:
250
+ return float(value)
251
+
252
+ return None
253
+
254
+
255
+ # =========================================================
256
+ # EXTRACT TOKEN END
257
+ # =========================================================
258
+
259
+ def _token_end(self, token):
260
+
261
+ if isinstance(token, dict):
262
+
263
+ for key in [
264
+ "end",
265
+ "end_ts",
266
+ "end_time"
267
+ ]:
268
+
269
+ if key in token:
270
+ return float(
271
+ token[key]
272
+ )
273
+
274
+ for attr in [
275
+ "end",
276
+ "end_ts",
277
+ "end_time"
278
+ ]:
279
+
280
+ if hasattr(token, attr):
281
+
282
+ value = getattr(
283
+ token,
284
+ attr
285
+ )
286
+
287
+ if value is not None:
288
+ return float(value)
289
+
290
+ return None
291
+
292
+
293
+ # =========================================================
294
+ # NORMALIZE TOKEN
295
+ # =========================================================
296
+
297
+ def _normalize(self, text):
298
+
299
+ return re.sub(
300
+ r"[^A-Za-z0-9']+",
301
+ "",
302
+ text
303
+ ).lower()
304
+
305
+
306
+ # =========================================================
307
+ # CREATE WORD TIMINGS
308
+ #
309
+ # Important:
310
+ #
311
+ # Kokoro can return token-level timing.
312
+ # We convert those timings into the ACTUAL
313
+ # words displayed by the Focus Reader.
314
+ # =========================================================
315
+
316
+ def _build_word_timings(
317
+ self,
318
+ tokens,
319
+ chunk_offset
320
+ ):
321
+
322
+ token_data = []
323
+
324
+ for token in tokens:
325
+
326
+ text = self._token_text(
327
+ token
328
+ )
329
+
330
+ start = self._token_start(
331
+ token
332
+ )
333
+
334
+ end = self._token_end(
335
+ token
336
+ )
337
+
338
+ if start is None or end is None:
339
+ continue
340
+
341
+ token_data.append({
342
+ "text": text,
343
+ "start": float(start) + chunk_offset,
344
+ "end": float(end) + chunk_offset
345
+ })
346
+
347
+ return token_data
348
+
349
+
350
+ # =========================================================
351
+ # MAP TOKENS -> ACTUAL WORDS
352
+ # =========================================================
353
+
354
+ def _tokens_to_words(
355
+ self,
356
+ token_data,
357
+ words
358
+ ):
359
+
360
+ result = []
361
+
362
+ token_index = 0
363
+
364
+ for word in words:
365
+
366
+ target = self._normalize(
367
+ word
368
+ )
369
+
370
+ # Empty / punctuation-only word
371
+ if not target:
372
+
373
+ result.append({
374
+ "text": word,
375
+ "start": None,
376
+ "end": None
377
+ })
378
+
379
+ continue
380
+
381
+ collected = ""
382
+
383
+ word_start = None
384
+ word_end = None
385
+
386
+ start_token_index = token_index
387
+
388
+ while token_index < len(token_data):
389
+
390
+ token = token_data[
391
+ token_index
392
+ ]
393
+
394
+ token_text = self._normalize(
395
+ token["text"]
396
+ )
397
+
398
+ # Ignore pure punctuation
399
+ if not token_text:
400
+
401
+ token_index += 1
402
+ continue
403
+
404
+ if word_start is None:
405
+
406
+ word_start = token[
407
+ "start"
408
+ ]
409
+
410
+ word_end = token[
411
+ "end"
412
+ ]
413
+
414
+ collected += token_text
415
+
416
+ token_index += 1
417
+
418
+ # We have consumed this word
419
+ if (
420
+ collected == target
421
+ or collected.startswith(target)
422
+ or target.startswith(collected)
423
+ ):
424
+
425
+ if collected == target:
426
+
427
+ break
428
+
429
+ if collected.startswith(target):
430
+
431
+ break
432
+
433
+ # If matching failed, try to use
434
+ # the first available token.
435
+ if word_start is None:
436
+
437
+ if start_token_index < len(
438
+ token_data
439
+ ):
440
+
441
+ word_start = token_data[
442
+ start_token_index
443
+ ]["start"]
444
+
445
+ word_end = token_data[
446
+ start_token_index
447
+ ]["end"]
448
+
449
+ result.append({
450
+ "text": word,
451
+ "start": word_start,
452
+ "end": word_end
453
+ })
454
+
455
+ return result
456
+
457
+
458
+ # =========================================================
459
+ # FIX MISSING TIMINGS
460
+ #
461
+ # If a punctuation / token mismatch occurs,
462
+ # interpolate missing word timing rather than
463
+ # deleting the word.
464
+ # =========================================================
465
+
466
+ def _fix_missing_timings(
467
+ self,
468
+ word_timings,
469
+ total_duration
470
+ ):
471
+
472
+ if not word_timings:
473
+ return word_timings
474
+
475
+ # First known timing
476
+ known = [
477
+ i
478
+ for i, item in enumerate(
479
+ word_timings
480
+ )
481
+ if item["start"] is not None
482
+ ]
483
+
484
+ if not known:
485
+ return word_timings
486
+
487
+ # Missing values before first known word
488
+ first = known[0]
489
+
490
+ if first > 0:
491
+
492
+ next_start = word_timings[
493
+ first
494
+ ]["start"]
495
+
496
+ step = next_start / (
497
+ first + 1
498
+ )
499
+
500
+ for i in range(first):
501
+
502
+ word_timings[i]["start"] = (
503
+ i * step
504
+ )
505
+
506
+ word_timings[i]["end"] = (
507
+ (i + 1) * step
508
+ )
509
+
510
+ # Missing values between known words
511
+ i = 0
512
+
513
+ while i < len(word_timings):
514
+
515
+ if word_timings[i]["start"] is not None:
516
+
517
+ i += 1
518
+ continue
519
+
520
+ start_missing = i
521
+
522
+ while (
523
+ i < len(word_timings)
524
+ and
525
+ word_timings[i]["start"] is None
526
+ ):
527
+
528
+ i += 1
529
+
530
+ end_missing = i - 1
531
+
532
+ previous = start_missing - 1
533
+ following = i
534
+
535
+ if (
536
+ previous >= 0
537
+ and following < len(word_timings)
538
+ and word_timings[previous]["end"] is not None
539
+ and word_timings[following]["start"] is not None
540
+ ):
541
+
542
+ a = word_timings[
543
+ previous
544
+ ]["end"]
545
+
546
+ b = word_timings[
547
+ following
548
+ ]["start"]
549
+
550
+ count = (
551
+ end_missing
552
+ - start_missing
553
+ + 2
554
+ )
555
+
556
+ step = (
557
+ b - a
558
+ ) / count
559
+
560
+ for j in range(
561
+ start_missing,
562
+ end_missing + 1
563
+ ):
564
+
565
+ k = (
566
+ j
567
+ - start_missing
568
+ + 1
569
+ )
570
+
571
+ word_timings[j][
572
+ "start"
573
+ ] = a + step * (k - 1)
574
+
575
+ word_timings[j][
576
+ "end"
577
+ ] = a + step * k
578
+
579
+ # Missing values at end
580
+ last_known = None
581
+
582
+ for i in range(
583
+ len(word_timings)
584
+ ):
585
+
586
+ if word_timings[i][
587
+ "start"
588
+ ] is not None:
589
+
590
+ last_known = i
591
+
592
+ if (
593
+ last_known is not None
594
+ and last_known < len(
595
+ word_timings
596
+ ) - 1
597
+ ):
598
+
599
+ start = word_timings[
600
+ last_known
601
+ ]["end"]
602
+
603
+ remaining = (
604
+ len(word_timings)
605
+ - last_known
606
+ - 1
607
+ )
608
+
609
+ duration = max(
610
+ 0.05,
611
+ (
612
+ total_duration
613
+ - start
614
+ ) / (remaining + 1)
615
+ )
616
+
617
+ for j in range(
618
+ last_known + 1,
619
+ len(word_timings)
620
+ ):
621
+
622
+ word_timings[j][
623
+ "start"
624
+ ] = (
625
+ start
626
+ + duration
627
+ * (
628
+ j
629
+ - last_known
630
+ - 1
631
+ )
632
+ )
633
+
634
+ word_timings[j][
635
+ "end"
636
+ ] = (
637
+ start
638
+ + duration
639
+ * (
640
+ j
641
+ - last_known
642
+ )
643
+ )
644
+
645
+ # Final safety clamp
646
+ for item in word_timings:
647
+
648
+ if item["start"] is None:
649
+ item["start"] = 0.0
650
+
651
+ if item["end"] is None:
652
+ item["end"] = (
653
+ item["start"]
654
+ + 0.05
655
+ )
656
+
657
+ if item["end"] < item["start"]:
658
+
659
+ item["end"] = (
660
+ item["start"]
661
+ + 0.05
662
+ )
663
+
664
+ return word_timings
665
+
666
+
667
+ # =========================================================
668
+ # GENERATE AUDIO + TIMINGS
669
+ # =========================================================
670
+
671
+ def generate(
672
+ self,
673
+ file="test.wav",
674
+ force=False
675
+ ):
676
+
677
+ print("Test tube ")
678
+ self.file = file
679
+
680
+ self.timings_file = (file+ "_timings.json")
681
+
682
+
683
+ print(f"Test tube = {self.file}, {self.timings_file}")
684
+
685
+
686
+ # =====================================================
687
+ # CACHE
688
+ # =====================================================
689
+
690
+ if (
691
+ not force
692
+ and os.path.exists(file)
693
+ and os.path.exists(
694
+ self.timings_file
695
+ )
696
+ ):
697
+
698
+ print(
699
+ "Using cached audio and timings."
700
+ )
701
+
702
+ with open(
703
+ self.timings_file,
704
+ "r",
705
+ encoding="utf-8"
706
+ ) as f:
707
+
708
+ self.word_timings = json.load(
709
+ f
710
+ )
711
+
712
+ return
713
+
714
+
715
+ print(
716
+ "Generating Kokoro audio..."
717
+ )
718
+
719
+ audio_parts = []
720
+
721
+ all_token_data = []
722
+
723
+ chunk_offset = 0.0
724
+
725
+ # =====================================================
726
+ # GENERATOR
727
+ # =====================================================
728
+
729
+ generator = self.pipeline(
730
+ self.text,
731
+ voice=self.voice,
732
+ speed=self.speed
733
+ )
734
+
735
+ for result in generator:
736
+
737
+ if result.audio is not None:
738
+
739
+ audio = (
740
+ result.audio
741
+ .detach()
742
+ .cpu()
743
+ .numpy()
744
+ )
745
+
746
+ audio_parts.append(
747
+ audio
748
+ )
749
+
750
+ chunk_duration = (
751
+ len(audio)
752
+ / 24000.0
753
+ )
754
+
755
+ # =================================================
756
+ # TOKEN TIMINGS
757
+ # =================================================
758
+
759
+ if hasattr(
760
+ result,
761
+ "tokens"
762
+ ):
763
+
764
+ tokens = result.tokens
765
+
766
+ token_data = (
767
+ self._build_word_timings(
768
+ tokens,
769
+ chunk_offset
770
+ )
771
+ )
772
+
773
+ all_token_data.extend(
774
+ token_data
775
+ )
776
+
777
+ chunk_offset += (
778
+ chunk_duration
779
+ )
780
+
781
+ if not audio_parts:
782
+
783
+ raise RuntimeError(
784
+ "Kokoro did not generate audio."
785
+ )
786
+
787
+ # =====================================================
788
+ # JOIN AUDIO
789
+ # =====================================================
790
+
791
+ audio = np.concatenate(
792
+ audio_parts
793
+ )
794
+
795
+ sf.write(
796
+ file,
797
+ audio,
798
+ 24000
799
+ )
800
+
801
+ total_duration = (
802
+ len(audio)
803
+ / 24000.0
804
+ )
805
+
806
+ # =====================================================
807
+ # MAP TOKEN TIMINGS TO REAL WORDS
808
+ # =====================================================
809
+
810
+ all_words = []
811
+
812
+ for sentence in self.sentences:
813
+
814
+ all_words.extend(
815
+ sentence["words"]
816
+ )
817
+
818
+ self.word_timings = (
819
+ self._tokens_to_words(
820
+ all_token_data,
821
+ all_words
822
+ )
823
+ )
824
+
825
+ # =====================================================
826
+ # FIX ANY MISSING TIMINGS
827
+ # =====================================================
828
+
829
+ self.word_timings = (
830
+ self._fix_missing_timings(
831
+ self.word_timings,
832
+ total_duration
833
+ )
834
+ )
835
+
836
+ # =====================================================
837
+ # SAVE TIMINGS
838
+ # =====================================================
839
+
840
+ with open(
841
+ self.timings_file,
842
+ "w",
843
+ encoding="utf-8"
844
+ ) as f:
845
+
846
+ json.dump(
847
+ self.word_timings,
848
+ f,
849
+ ensure_ascii=False,
850
+ indent=2
851
+ )
852
+
853
+ print()
854
+ print(
855
+ "Audio saved:",
856
+ file
857
+ )
858
+
859
+ print(
860
+ "Timings saved:",
861
+ self.timings_file
862
+ )
863
+
864
+ print(
865
+ "Duration:",
866
+ round(
867
+ total_duration,
868
+ 2
869
+ ),
870
+ "seconds"
871
+ )
872
+
873
+
874
+ # =========================================================
875
+ # DISPLAY AUDIO
876
+ # =========================================================
877
+
878
+ def display(
879
+ self,
880
+ autoplay=True
881
+ ):
882
+
883
+ if self.file is None:
884
+
885
+ raise RuntimeError(
886
+ "Run generate() first."
887
+ )
888
+
889
+ display(
890
+ Audio(
891
+ self.file,
892
+ autoplay=autoplay
893
+ )
894
+ )
895
+
896
+
897
+ # =========================================================
898
+ # DISPLAY ANOTHER AUDIO FILE
899
+ # =========================================================
900
+
901
+ def display_audio(
902
+ self,
903
+ file,
904
+ autoplay=True
905
+ ):
906
+
907
+ display(
908
+ Audio(
909
+ file,
910
+ autoplay=autoplay
911
+ )
912
+ )
913
+
914
+
915
+ # =========================================================
916
+ # CREATE ALIGNED READER HTML
917
+ # =========================================================
918
+
919
+ def _aligned_reader_html(self):
920
+
921
+ output = ""
922
+
923
+ for sentence in self.sentences:
924
+
925
+ output += (
926
+ '<div class="t2s-aligned-sentence">'
927
+ )
928
+
929
+ output += html.escape(
930
+ sentence["text"]
931
+ )
932
+
933
+ output += "</div>"
934
+
935
+ return output
936
+
937
+
938
+ # =========================================================
939
+ # CREATE FOCUS READER HTML
940
+ # =========================================================
941
+
942
+ def _focus_reader_html(self):
943
+
944
+ output = ""
945
+
946
+ global_index = 0
947
+
948
+ timing_index = 0
949
+
950
+ for sentence in self.sentences:
951
+
952
+ output += (
953
+ '<div class="t2s-focus-sentence">'
954
+ )
955
+
956
+ for word in sentence["words"]:
957
+
958
+ if (
959
+ timing_index
960
+ < len(self.word_timings)
961
+ ):
962
+
963
+ timing = (
964
+ self.word_timings[
965
+ timing_index
966
+ ]
967
+ )
968
+
969
+ else:
970
+
971
+ timing = {
972
+ "start": 0.0,
973
+ "end": 0.05
974
+ }
975
+
976
+ start = float(
977
+ timing["start"]
978
+ )
979
+
980
+ end = float(
981
+ timing["end"]
982
+ )
983
+
984
+ output += f'''
985
+ <span
986
+ class="t2s-word"
987
+ data-index="{global_index}"
988
+ data-start="{start:.4f}"
989
+ data-end="{end:.4f}"
990
+ >{html.escape(word)}</span> '''
991
+
992
+ global_index += 1
993
+ timing_index += 1
994
+
995
+ output += "</div>"
996
+
997
+ return output
998
+
999
+
1000
+
1001
+ def extract_text(self,filepath):
1002
+ try:
1003
+ with open(filepath, 'r') as file:
1004
+ content = file.read()
1005
+ return content
1006
+ except FileNotFoundError:
1007
+ print(f"File not found: {filepath}")
1008
+ return None
1009
+
1010
+ # =========================================================
1011
+ # READER
1012
+ # =========================================================
1013
+
1014
+ def reader(self):
1015
+
1016
+ if self.file is None:
1017
+
1018
+ raise RuntimeError(
1019
+ "Run generate() first."
1020
+ )
1021
+
1022
+ if not os.path.exists(
1023
+ self.file
1024
+ ):
1025
+
1026
+ raise FileNotFoundError(
1027
+ self.file
1028
+ )
1029
+
1030
+ # =====================================================
1031
+ # READ AUDIO
1032
+ # =====================================================
1033
+
1034
+ with open(
1035
+ self.file,
1036
+ "rb"
1037
+ ) as f:
1038
+
1039
+ audio_bytes = f.read()
1040
+
1041
+ audio_base64 = base64.b64encode(
1042
+ audio_bytes
1043
+ ).decode("utf-8")
1044
+
1045
+ # =====================================================
1046
+ # HTML
1047
+ # =====================================================
1048
+
1049
+ aligned_html = (
1050
+ self._aligned_reader_html()
1051
+ )
1052
+
1053
+ focus_html = (
1054
+ self._focus_reader_html()
1055
+ )
1056
+
1057
+ page = f"""
1058
+ <!DOCTYPE html>
1059
+
1060
+ <html>
1061
+
1062
+ <head>
1063
+
1064
+ <meta charset="UTF-8">
1065
+
1066
+ <style>
1067
+
1068
+ /* =========================================================
1069
+ MAIN
1070
+ ========================================================= */
1071
+
1072
+ .t2s-container {{
1073
+ width: 100%;
1074
+ max-width: 1100px;
1075
+
1076
+ margin: 20px auto;
1077
+
1078
+ font-family:
1079
+ Arial,
1080
+ Helvetica,
1081
+ sans-serif;
1082
+
1083
+ color: #111111;
1084
+ }}
1085
+
1086
+
1087
+ /* =========================================================
1088
+ TITLE
1089
+ ========================================================= */
1090
+
1091
+ .t2s-title {{
1092
+ font-size: 26px;
1093
+ font-weight: bold;
1094
+
1095
+ margin-top: 25px;
1096
+ margin-bottom: 12px;
1097
+
1098
+ color: #111111;
1099
+ }}
1100
+
1101
+
1102
+ /* =========================================================
1103
+ ALIGNED READER
1104
+ ========================================================= */
1105
+
1106
+ .t2s-aligned-reader {{
1107
+
1108
+ width: 100%;
1109
+
1110
+ box-sizing: border-box;
1111
+
1112
+ padding: 22px;
1113
+
1114
+ background: #ffffff;
1115
+
1116
+ border: 1px solid #dddddd;
1117
+
1118
+ border-radius: 10px;
1119
+
1120
+ color: #111111;
1121
+
1122
+ font-size: 22px;
1123
+
1124
+ line-height: 1.8;
1125
+
1126
+ text-align: left;
1127
+ }}
1128
+
1129
+
1130
+ .t2s-aligned-sentence {{
1131
+
1132
+ display: block;
1133
+
1134
+ margin-bottom: 16px;
1135
+
1136
+ color: #111111;
1137
+
1138
+ text-align: left;
1139
+
1140
+ white-space: normal;
1141
+
1142
+ overflow-wrap: break-word;
1143
+ }}
1144
+
1145
+
1146
+ /* =========================================================
1147
+ FOCUS READER
1148
+ ========================================================= */
1149
+
1150
+ .t2s-focus-reader {{
1151
+
1152
+ width: 100%;
1153
+
1154
+ box-sizing: border-box;
1155
+
1156
+ padding: 22px;
1157
+
1158
+ background: #ffffff;
1159
+
1160
+ border: 1px solid #dddddd;
1161
+
1162
+ border-radius: 10px;
1163
+
1164
+ color: #111111;
1165
+
1166
+ font-size: 28px;
1167
+
1168
+ line-height: 2.0;
1169
+
1170
+ text-align: left;
1171
+ }}
1172
+
1173
+
1174
+ /* =========================================================
1175
+ EACH SENTENCE IS ONE BLOCK
1176
+ ========================================================= */
1177
+
1178
+ .t2s-focus-sentence {{
1179
+
1180
+ display: block;
1181
+
1182
+ width: 100%;
1183
+
1184
+ margin-bottom: 20px;
1185
+
1186
+ padding: 5px 8px;
1187
+
1188
+ box-sizing: border-box;
1189
+
1190
+ color: #111111;
1191
+
1192
+ text-align: left;
1193
+
1194
+ white-space: normal;
1195
+
1196
+ overflow-wrap: break-word;
1197
+ }}
1198
+
1199
+
1200
+ /* =========================================================
1201
+ WORD
1202
+ ========================================================= */
1203
+
1204
+ .t2s-word {{
1205
+
1206
+ display: inline;
1207
+
1208
+ color: #111111;
1209
+
1210
+ background: transparent;
1211
+
1212
+ cursor: pointer;
1213
+
1214
+ border-radius: 5px;
1215
+
1216
+ padding: 2px 4px;
1217
+
1218
+ margin: 0;
1219
+
1220
+ transition:
1221
+ background 0.08s,
1222
+ color 0.08s;
1223
+ }}
1224
+
1225
+
1226
+ /* =========================================================
1227
+ CURRENT SPOKEN WORD
1228
+ ========================================================= */
1229
+
1230
+ .t2s-word.active {{
1231
+
1232
+ background: yellow;
1233
+
1234
+ color: #000000;
1235
+
1236
+ }}
1237
+
1238
+
1239
+ /* =========================================================
1240
+ AUDIO
1241
+ ========================================================= */
1242
+
1243
+ .t2s-audio {{
1244
+
1245
+ width: 100%;
1246
+
1247
+ margin-top: 15px;
1248
+
1249
+ }}
1250
+
1251
+
1252
+ /* =========================================================
1253
+ CONTROLS
1254
+ ========================================================= */
1255
+
1256
+ .t2s-controls {{
1257
+
1258
+ display: flex;
1259
+
1260
+ flex-wrap: wrap;
1261
+
1262
+ gap: 8px;
1263
+
1264
+ margin-top: 15px;
1265
+
1266
+ margin-bottom: 15px;
1267
+ }}
1268
+
1269
+
1270
+ .t2s-controls button,
1271
+ .t2s-controls select {{
1272
+
1273
+ font-size: 16px;
1274
+
1275
+ padding: 8px 14px;
1276
+
1277
+ border: 1px solid #cccccc;
1278
+
1279
+ border-radius: 6px;
1280
+
1281
+ background: #ffffff;
1282
+
1283
+ color: #111111;
1284
+
1285
+ cursor: pointer;
1286
+ }}
1287
+
1288
+
1289
+ .t2s-controls button:hover {{
1290
+
1291
+ background: #eeeeee;
1292
+ }}
1293
+
1294
+
1295
+ </style>
1296
+
1297
+ </head>
1298
+
1299
+
1300
+ <body>
1301
+
1302
+ <div class="t2s-container">
1303
+
1304
+
1305
+ <div class="t2s-title">
1306
+ Aligned Reader
1307
+ </div>
1308
+
1309
+
1310
+ <div class="t2s-aligned-reader">
1311
+
1312
+ {aligned_html}
1313
+
1314
+ </div>
1315
+
1316
+
1317
+ <div class="t2s-title">
1318
+ Focus Reader
1319
+ </div>
1320
+
1321
+
1322
+ <div class="t2s-focus-reader"
1323
+ id="t2sFocusReader">
1324
+
1325
+ {focus_html}
1326
+
1327
+ </div>
1328
+
1329
+
1330
+ <audio
1331
+ id="t2sAudio"
1332
+ class="t2s-audio"
1333
+ controls>
1334
+
1335
+ <source
1336
+ src="data:audio/wav;base64,{audio_base64}"
1337
+ type="audio/wav">
1338
+
1339
+ </audio>
1340
+
1341
+
1342
+ <div class="t2s-controls">
1343
+
1344
+ <button id="t2sPrevious">
1345
+ Previous
1346
+ </button>
1347
+
1348
+ <button id="t2sNext">
1349
+ Next
1350
+ </button>
1351
+
1352
+ <button id="t2sRepeat">
1353
+ Repeat Word
1354
+ </button>
1355
+
1356
+ <button id="t2sPlayPause">
1357
+ Play / Pause
1358
+ </button>
1359
+
1360
+ <select id="t2sSpeed">
1361
+
1362
+ <option value="0.75">
1363
+ 0.75x
1364
+ </option>
1365
+
1366
+ <option value="0.9" selected>
1367
+ 0.9x
1368
+ </option>
1369
+
1370
+ <option value="1.0">
1371
+ 1.0x
1372
+ </option>
1373
+
1374
+ <option value="1.1">
1375
+ 1.1x
1376
+ </option>
1377
+
1378
+ <option value="1.25">
1379
+ 1.25x
1380
+ </option>
1381
+
1382
+ <option value="1.5">
1383
+ 1.5x
1384
+ </option>
1385
+
1386
+ </select>
1387
+
1388
+ </div>
1389
+
1390
+
1391
+ </div>
1392
+
1393
+
1394
+ <script>
1395
+
1396
+
1397
+ // =========================================================
1398
+ // AUDIO
1399
+ // =========================================================
1400
+
1401
+ const t2sAudio =
1402
+ document.getElementById(
1403
+ "t2sAudio"
1404
+ );
1405
+
1406
+
1407
+ // =========================================================
1408
+ // WORDS
1409
+ // =========================================================
1410
+
1411
+ const t2sWords =
1412
+ Array.from(
1413
+ document.querySelectorAll(
1414
+ ".t2s-word"
1415
+ )
1416
+ );
1417
+
1418
+
1419
+ // =========================================================
1420
+ // CURRENT WORD
1421
+ // =========================================================
1422
+
1423
+ let t2sCurrentWord = -1;
1424
+
1425
+
1426
+ // =========================================================
1427
+ // REMOVE HIGHLIGHT
1428
+ // =========================================================
1429
+
1430
+ function t2sRemoveHighlight() {{
1431
+
1432
+ if (
1433
+ t2sCurrentWord >= 0 &&
1434
+ t2sCurrentWord <
1435
+ t2sWords.length
1436
+ ) {{
1437
+
1438
+ t2sWords[
1439
+ t2sCurrentWord
1440
+ ].classList.remove(
1441
+ "active"
1442
+ );
1443
+
1444
+ }}
1445
+
1446
+ }}
1447
+
1448
+
1449
+ // =========================================================
1450
+ // SET HIGHLIGHT
1451
+ // =========================================================
1452
+
1453
+ function t2sSetWord(index) {{
1454
+
1455
+ if (
1456
+ index < 0 ||
1457
+ index >= t2sWords.length
1458
+ ) {{
1459
+ return;
1460
+ }}
1461
+
1462
+ t2sRemoveHighlight();
1463
+
1464
+ t2sCurrentWord = index;
1465
+
1466
+ t2sWords[
1467
+ index
1468
+ ].classList.add(
1469
+ "active"
1470
+ );
1471
+
1472
+ // Keep current word visible
1473
+ t2sWords[
1474
+ index
1475
+ ].scrollIntoView({{
1476
+ behavior: "smooth",
1477
+ block: "center"
1478
+ }});
1479
+
1480
+ }}
1481
+
1482
+
1483
+ // =========================================================
1484
+ // FIND WORD FROM AUDIO TIME
1485
+ // =========================================================
1486
+
1487
+ function t2sFindWord(time) {{
1488
+
1489
+ // First try current word.
1490
+ // This prevents unnecessary jumping.
1491
+
1492
+ if (
1493
+ t2sCurrentWord >= 0 &&
1494
+ t2sCurrentWord <
1495
+ t2sWords.length
1496
+ ) {{
1497
+
1498
+ const current =
1499
+ t2sWords[
1500
+ t2sCurrentWord
1501
+ ];
1502
+
1503
+ const start =
1504
+ parseFloat(
1505
+ current.dataset.start
1506
+ );
1507
+
1508
+ const end =
1509
+ parseFloat(
1510
+ current.dataset.end
1511
+ );
1512
+
1513
+ if (
1514
+ time >= start &&
1515
+ time < end
1516
+ ) {{
1517
+ return t2sCurrentWord;
1518
+ }}
1519
+
1520
+ }}
1521
+
1522
+
1523
+ // Search all words
1524
+
1525
+ for (
1526
+ let i = 0;
1527
+ i < t2sWords.length;
1528
+ i++
1529
+ ) {{
1530
+
1531
+ const word =
1532
+ t2sWords[i];
1533
+
1534
+ const start =
1535
+ parseFloat(
1536
+ word.dataset.start
1537
+ );
1538
+
1539
+ const end =
1540
+ parseFloat(
1541
+ word.dataset.end
1542
+ );
1543
+
1544
+ if (
1545
+ time >= start &&
1546
+ time < end
1547
+ ) {{
1548
+ return i;
1549
+ }}
1550
+
1551
+ }}
1552
+
1553
+ // If audio is between words,
1554
+ // use the next word.
1555
+
1556
+ for (
1557
+ let i = 0;
1558
+ i < t2sWords.length;
1559
+ i++
1560
+ ) {{
1561
+
1562
+ const start =
1563
+ parseFloat(
1564
+ t2sWords[i]
1565
+ .dataset
1566
+ .start
1567
+ );
1568
+
1569
+ if (
1570
+ time < start
1571
+ ) {{
1572
+ return i;
1573
+ }}
1574
+
1575
+ }}
1576
+
1577
+ return -1;
1578
+
1579
+ }}
1580
+
1581
+
1582
+ // =========================================================
1583
+ // AUDIO TIME UPDATE
1584
+ // =========================================================
1585
+
1586
+ t2sAudio.addEventListener(
1587
+ "timeupdate",
1588
+ function() {{
1589
+
1590
+ const time =
1591
+ t2sAudio.currentTime;
1592
+
1593
+ const index =
1594
+ t2sFindWord(time);
1595
+
1596
+ if (
1597
+ index >= 0 &&
1598
+ index !== t2sCurrentWord
1599
+ ) {{
1600
+
1601
+ t2sSetWord(
1602
+ index
1603
+ );
1604
+
1605
+ }}
1606
+
1607
+ }}
1608
+ );
1609
+
1610
+
1611
+ // =========================================================
1612
+ // CLICK WORD
1613
+ // =========================================================
1614
+
1615
+ t2sWords.forEach(
1616
+ function(word, index) {{
1617
+
1618
+ word.addEventListener(
1619
+ "click",
1620
+ function() {{
1621
+
1622
+ // IMPORTANT:
1623
+ // Use index, NOT index - 1
1624
+
1625
+ t2sSetWord(
1626
+ index
1627
+ );
1628
+
1629
+ // Start exactly at
1630
+ // clicked word
1631
+
1632
+ const startTime =
1633
+ parseFloat(
1634
+ word.dataset.start
1635
+ );
1636
+
1637
+ if (
1638
+ !isNaN(startTime)
1639
+ ) {{
1640
+
1641
+ t2sAudio.currentTime =
1642
+ startTime;
1643
+
1644
+ }}
1645
+
1646
+ const playPromise =
1647
+ t2sAudio.play();
1648
+
1649
+ if (
1650
+ playPromise !==
1651
+ undefined
1652
+ ) {{
1653
+
1654
+ playPromise.catch(
1655
+ function(error) {{
1656
+
1657
+ console.log(
1658
+ "Audio play error:",
1659
+ error
1660
+ );
1661
+
1662
+ }}
1663
+ );
1664
+
1665
+ }}
1666
+
1667
+ }}
1668
+ );
1669
+
1670
+ }}
1671
+ );
1672
+
1673
+
1674
+ // =========================================================
1675
+ // PREVIOUS WORD
1676
+ // =========================================================
1677
+
1678
+ document
1679
+ .getElementById(
1680
+ "t2sPrevious"
1681
+ )
1682
+ .addEventListener(
1683
+ "click",
1684
+ function() {{
1685
+
1686
+ if (
1687
+ t2sCurrentWord <= 0
1688
+ ) {{
1689
+ return;
1690
+ }}
1691
+
1692
+ const index =
1693
+ t2sCurrentWord - 1;
1694
+
1695
+ const word =
1696
+ t2sWords[index];
1697
+
1698
+ t2sSetWord(
1699
+ index
1700
+ );
1701
+
1702
+ t2sAudio.currentTime =
1703
+ parseFloat(
1704
+ word.dataset.start
1705
+ );
1706
+
1707
+ t2sAudio.play();
1708
+
1709
+ }}
1710
+ );
1711
+
1712
+
1713
+ // =========================================================
1714
+ // NEXT WORD
1715
+ // =========================================================
1716
+
1717
+ document
1718
+ .getElementById(
1719
+ "t2sNext"
1720
+ )
1721
+ .addEventListener(
1722
+ "click",
1723
+ function() {{
1724
+
1725
+ if (
1726
+ t2sCurrentWord >=
1727
+ t2sWords.length - 1
1728
+ ) {{
1729
+ return;
1730
+ }}
1731
+
1732
+ const index =
1733
+ t2sCurrentWord + 1;
1734
+
1735
+ const word =
1736
+ t2sWords[index];
1737
+
1738
+ t2sSetWord(
1739
+ index
1740
+ );
1741
+
1742
+ t2sAudio.currentTime =
1743
+ parseFloat(
1744
+ word.dataset.start
1745
+ );
1746
+
1747
+ t2sAudio.play();
1748
+
1749
+ }}
1750
+ );
1751
+
1752
+
1753
+ // =========================================================
1754
+ // REPEAT CURRENT WORD
1755
+ // =========================================================
1756
+
1757
+ document
1758
+ .getElementById(
1759
+ "t2sRepeat"
1760
+ )
1761
+ .addEventListener(
1762
+ "click",
1763
+ function() {{
1764
+
1765
+ if (
1766
+ t2sCurrentWord < 0 ||
1767
+ t2sCurrentWord >=
1768
+ t2sWords.length
1769
+ ) {{
1770
+ return;
1771
+ }}
1772
+
1773
+ const word =
1774
+ t2sWords[
1775
+ t2sCurrentWord
1776
+ ];
1777
+
1778
+ const start =
1779
+ parseFloat(
1780
+ word.dataset.start
1781
+ );
1782
+
1783
+ const end =
1784
+ parseFloat(
1785
+ word.dataset.end
1786
+ );
1787
+
1788
+ t2sAudio.currentTime =
1789
+ start;
1790
+
1791
+ t2sAudio.play();
1792
+
1793
+ // Stop at end of word
1794
+
1795
+ function stopAtEnd() {{
1796
+
1797
+ if (
1798
+ t2sAudio.currentTime >=
1799
+ end
1800
+ ) {{
1801
+
1802
+ t2sAudio.pause();
1803
+
1804
+ t2sAudio.currentTime =
1805
+ start;
1806
+
1807
+ t2sAudio.removeEventListener(
1808
+ "timeupdate",
1809
+ stopAtEnd
1810
+ );
1811
+
1812
+ }}
1813
+
1814
+ }}
1815
+
1816
+ t2sAudio.addEventListener(
1817
+ "timeupdate",
1818
+ stopAtEnd
1819
+ );
1820
+
1821
+ }}
1822
+ );
1823
+
1824
+
1825
+ // =========================================================
1826
+ // PLAY / PAUSE
1827
+ // =========================================================
1828
+
1829
+ document
1830
+ .getElementById(
1831
+ "t2sPlayPause"
1832
+ )
1833
+ .addEventListener(
1834
+ "click",
1835
+ function() {{
1836
+
1837
+ if (
1838
+ t2sAudio.paused
1839
+ ) {{
1840
+
1841
+ t2sAudio.play();
1842
+
1843
+ }}
1844
+ else {{
1845
+
1846
+ t2sAudio.pause();
1847
+
1848
+ }}
1849
+
1850
+ }}
1851
+ );
1852
+
1853
+
1854
+ // =========================================================
1855
+ // SPEED
1856
+ // =========================================================
1857
+
1858
+ document
1859
+ .getElementById(
1860
+ "t2sSpeed"
1861
+ )
1862
+ .addEventListener(
1863
+ "change",
1864
+ function() {{
1865
+
1866
+ t2sAudio.playbackRate =
1867
+ parseFloat(
1868
+ this.value
1869
+ );
1870
+
1871
+ }}
1872
+ );
1873
+
1874
+
1875
+ // =========================================================
1876
+ // AUDIO ENDED
1877
+ // =========================================================
1878
+
1879
+ t2sAudio.addEventListener(
1880
+ "ended",
1881
+ function() {{
1882
+
1883
+ // Remove highlight
1884
+
1885
+ t2sWords.forEach(
1886
+ function(word) {{
1887
+
1888
+ word.classList.remove(
1889
+ "active"
1890
+ );
1891
+
1892
+ }}
1893
+ );
1894
+
1895
+ // Reset
1896
+
1897
+ t2sCurrentWord = -1;
1898
+
1899
+ }}
1900
+ );
1901
+
1902
+
1903
+ // =========================================================
1904
+ // INITIAL STATE
1905
+ // =========================================================
1906
+
1907
+ t2sAudio.playbackRate = {self.speed};
1908
+
1909
+ </script>
1910
+
1911
+
1912
+ </body>
1913
+
1914
+ </html>
1915
+ """
1916
+
1917
+ display(
1918
+ HTML(page)
1919
+ )
1920
+
1921
+
1922
+ def male_voice():
1923
+ male = ["am_michael", "am_fenrir", "am_puck", "am_echo", "am_eric", "am_liam", "am_onyx", "am_santa", "am_adam","bm_george", "bm_fable", "bm_lewis", "bm_daniel"]
1924
+ return male
1925
+
1926
+ def female_voice():
1927
+ female = ["af_heart" , "af_bella" , "af_nicole" , "af_aoede", "af_kore", "af_sarah", "af_nova", "af_sky", "af_alloy", "af_jessica", "af_river","bf_emma", "bf_isabella", "bf_alice", "bf_lily"]
1928
+ return female
1929
+
1930
+
1931
+ def exec(filename="example",speed=1.0,voice="am_santa",isReadView=True):
1932
+ t2s = T2S(filename=f"{filename}.txt",speed=speed,voice=voice)
1933
+ t2s.generate(f"{filename}.wav")
1934
+ if isReadView:
1935
+ t2s.display()
1936
+ t2s.reader()
1937
+ return t2s