Tom199328 commited on
Commit
590dfc1
·
verified ·
1 Parent(s): ddbbe6b

Upload folder using huggingface_hub

Browse files
Files changed (4) hide show
  1. app.py +6 -1710
  2. extract_html.py +39 -0
  3. split_app.py +29 -0
  4. ui/templates/index.html +1706 -0
app.py CHANGED
@@ -154,7 +154,7 @@ class WarmupSnapshot:
154
 
155
 
156
  class WarmupManager:
157
- def __init__(self, runtime: NanoTTSService, text_normalizer_manager: "WeTextProcessingManager | None" = None) -> None:
158
  self.runtime = runtime
159
  self.text_normalizer_manager = text_normalizer_manager
160
  self._lock = threading.Lock()
@@ -666,1714 +666,10 @@ def _render_index_html(
666
  text_normalization_status: str,
667
  ) -> str:
668
  base_path = request.scope.get("root_path", "").rstrip("/")
669
- template = """
670
- <!doctype html>
671
- <html lang="en">
672
- <head>
673
- <meta charset="utf-8">
674
- <meta name="viewport" content="width=device-width, initial-scale=1">
675
- <title>MOSS-TTS-Nano Demo</title>
676
- <style>
677
- :root {
678
- color-scheme: light;
679
- --bg: #f3f5f9;
680
- --bg-soft: #f8fafc;
681
- --panel: rgba(255, 255, 255, 0.75);
682
- --text: #1e293b;
683
- --muted: #64748b;
684
- --line: rgba(226, 232, 240, 0.8);
685
- --line-strong: #cbd5e1;
686
- --chip: #e0e7ff;
687
- --chip-text: #4338ca;
688
- --accent: #6366f1;
689
- --accent-strong: #4f46e5;
690
- --accent-soft: rgba(99, 102, 241, 0.12);
691
- --danger: #e11d48;
692
- --shadow: 0 10px 25px rgba(0, 0, 0, 0.05);
693
- }
694
- * {
695
- box-sizing: border-box;
696
- }
697
- body {
698
- margin: 0;
699
- min-height: 100vh;
700
- background:
701
- radial-gradient(1100px 380px at 12% -10%, #ffffff 0%, transparent 70%),
702
- linear-gradient(180deg, var(--bg-soft) 0%, var(--bg) 54%);
703
- color: var(--text);
704
- font-family: "Plus Jakarta Sans", "Noto Sans SC", "Segoe UI", sans-serif;
705
- }
706
- .page {
707
- max-width: 1460px;
708
- margin: 0 auto;
709
- padding: 24px 26px 30px;
710
- }
711
- .hero {
712
- margin-bottom: 12px;
713
- }
714
- .hero h1 {
715
- margin: 0 0 10px;
716
- font-size: 38px;
717
- letter-spacing: -0.03em;
718
- }
719
- .hero .lead {
720
- margin: 0 0 8px;
721
- color: var(--muted);
722
- font-size: 19px;
723
- line-height: 1.55;
724
- max-width: 980px;
725
- }
726
- .hero-points {
727
- margin: 0 0 8px 20px;
728
- padding: 0;
729
- color: #33415f;
730
- line-height: 1.6;
731
- }
732
- .hero-points strong {
733
- color: #121826;
734
- }
735
- .build-note {
736
- margin: 0;
737
- color: #4f5d7c;
738
- }
739
- .top-tabs {
740
- display: flex;
741
- align-items: center;
742
- gap: 22px;
743
- margin-top: 14px;
744
- border-bottom: 1px solid var(--line);
745
- }
746
- .top-tab {
747
- border: 0;
748
- background: transparent;
749
- color: #4e5f89;
750
- font-size: 15px;
751
- font-weight: 500;
752
- padding: 10px 0;
753
- position: relative;
754
- cursor: default;
755
- }
756
- .top-tab.active {
757
- color: var(--accent-strong);
758
- font-weight: 700;
759
- }
760
- .top-tab.active::after {
761
- content: "";
762
- position: absolute;
763
- left: 0;
764
- right: 0;
765
- bottom: -1px;
766
- height: 2px;
767
- background: var(--accent-strong);
768
- }
769
- .top-tab:disabled {
770
- opacity: 1;
771
- }
772
- .grid {
773
- display: grid;
774
- grid-template-columns: minmax(0, 1.08fr) minmax(320px, 0.92fr);
775
- gap: 14px;
776
- margin-top: 14px;
777
- }
778
- .panel {
779
- background: var(--panel);
780
- backdrop-filter: blur(24px);
781
- -webkit-backdrop-filter: blur(24px);
782
- border: 1px solid rgba(255, 255, 255, 0.6);
783
- border-radius: 16px;
784
- padding: 20px;
785
- box-shadow: var(--shadow), inset 0 1px 0 rgba(255, 255, 255, 0.8);
786
- }
787
- .field {
788
- margin-bottom: 11px;
789
- }
790
- .field > label[for],
791
- .field > .field-tag {
792
- display: inline-flex;
793
- align-items: center;
794
- gap: 6px;
795
- margin-bottom: 7px;
796
- font-size: 14px;
797
- line-height: 1;
798
- font-weight: 700;
799
- color: var(--chip-text);
800
- background: var(--chip);
801
- border-radius: 6px;
802
- padding: 5px 8px;
803
- }
804
- .field > label[for]::before,
805
- .field > .field-tag::before {
806
- content: "♫";
807
- font-size: 11px;
808
- opacity: 0.75;
809
- }
810
- .field > label:not([for]):not(.field-tag) {
811
- display: inline-flex;
812
- align-items: center;
813
- gap: 8px;
814
- margin-top: 2px;
815
- color: #273554;
816
- font-size: 14px;
817
- font-weight: 500;
818
- }
819
- .field input,
820
- .field textarea,
821
- .field select {
822
- width: 100%;
823
- border: 1px solid var(--line);
824
- border-radius: 8px;
825
- padding: 10px 12px;
826
- font-size: 14px;
827
- color: var(--text);
828
- background: #fff;
829
- transition: border-color 140ms ease, box-shadow 140ms ease;
830
- }
831
- .field input:focus,
832
- .field textarea:focus,
833
- .field select:focus {
834
- outline: 0;
835
- border-color: #aeb9f7;
836
- box-shadow: 0 0 0 3px rgba(106, 110, 246, 0.15);
837
- }
838
- .field textarea {
839
- min-height: 108px;
840
- resize: vertical;
841
- }
842
- #normalized-text-output {
843
- min-height: 114px;
844
- background: #fbfcff;
845
- }
846
- input[type="file"] {
847
- border-style: dashed;
848
- border-color: var(--line-strong);
849
- padding: 54px 12px;
850
- background: linear-gradient(180deg, #ffffff 0%, #f5f8ff 100%);
851
- color: #4d5d83;
852
- }
853
- input[type="file"]::file-selector-button {
854
- border: 0;
855
- border-radius: 999px;
856
- padding: 8px 12px;
857
- margin-right: 10px;
858
- background: #edf1ff;
859
- color: var(--chip-text);
860
- font-weight: 700;
861
- cursor: pointer;
862
- }
863
- .prompt-audio-box {
864
- border: 1px solid var(--line);
865
- border-radius: 8px;
866
- background: linear-gradient(180deg, #ffffff 0%, #f7f9ff 100%);
867
- padding: 10px;
868
- }
869
- #prompt-audio-preview {
870
- margin-top: 0;
871
- }
872
- #prompt-audio-upload[hidden],
873
- #prompt-audio-preview[hidden] {
874
- display: none;
875
- }
876
- .prompt-audio-actions {
877
- display: flex;
878
- gap: 8px;
879
- margin-top: 8px;
880
- flex-wrap: wrap;
881
- }
882
- .prompt-audio-actions button {
883
- min-height: 34px;
884
- font-size: 13px;
885
- padding: 8px 12px;
886
- }
887
- .row {
888
- display: grid;
889
- grid-template-columns: 1fr 1fr;
890
- gap: 10px;
891
- }
892
- details {
893
- border: 1px solid var(--line);
894
- border-radius: 10px;
895
- padding: 10px 12px;
896
- margin: 12px 0;
897
- background: #f9fafe;
898
- }
899
- summary {
900
- cursor: pointer;
901
- font-weight: 600;
902
- color: #2f3f65;
903
- }
904
- .buttons {
905
- display: grid;
906
- grid-template-columns: minmax(0, 1fr) auto auto;
907
- gap: 10px;
908
- margin-top: 12px;
909
- }
910
- button:not(.top-tab) {
911
- border: 0;
912
- border-radius: 10px;
913
- padding: 12px 18px;
914
- font-size: 15px;
915
- font-weight: 700;
916
- cursor: pointer;
917
- background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
918
- color: #fff;
919
- transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
920
- box-shadow: 0 4px 6px rgba(79, 70, 229, 0.15);
921
- }
922
- button:not(.top-tab):hover {
923
- transform: translateY(-2px);
924
- box-shadow: 0 8px 15px rgba(79, 70, 229, 0.3);
925
- }
926
- #generate-btn {
927
- width: 100%;
928
- min-height: 42px;
929
- }
930
- button.secondary {
931
- background: #edf1fb;
932
- color: #44527a;
933
- border: 1px solid var(--line-strong);
934
- }
935
- button:not(.top-tab):disabled {
936
- opacity: 0.6;
937
- cursor: wait;
938
- transform: none;
939
- box-shadow: none;
940
- }
941
- .status {
942
- white-space: pre-wrap;
943
- line-height: 1.5;
944
- font-size: 14px;
945
- color: var(--muted);
946
- min-height: 52px;
947
- border: 1px solid var(--line);
948
- border-radius: 8px;
949
- background: #fcfdff;
950
- padding: 10px 12px;
951
- }
952
- .status.error {
953
- color: var(--danger);
954
- border-color: rgba(186, 31, 70, 0.28);
955
- background: rgba(186, 31, 70, 0.06);
956
- }
957
- .meta {
958
- font-size: 13px;
959
- color: var(--muted);
960
- margin-top: 7px;
961
- line-height: 1.5;
962
- }
963
- .playback-script {
964
- min-height: 108px;
965
- display: flex;
966
- flex-wrap: wrap;
967
- align-content: flex-start;
968
- gap: 10px;
969
- padding: 12px;
970
- border: 1px solid var(--line);
971
- border-radius: 8px;
972
- background: linear-gradient(180deg, #ffffff 0%, #f6f8fe 100%);
973
- overflow: auto;
974
- }
975
- .playback-script.empty {
976
- display: block;
977
- color: var(--muted);
978
- }
979
- .playback-segment {
980
- display: inline-flex;
981
- align-items: center;
982
- padding: 8px 11px;
983
- border-radius: 10px;
984
- border: 1px solid var(--line);
985
- background: #ffffff;
986
- color: #4a5c82;
987
- line-height: 1.6;
988
- transition: background-color 160ms ease, color 160ms ease, border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
989
- }
990
- .playback-segment.played {
991
- border-color: rgba(106, 110, 246, 0.24);
992
- background: var(--accent-soft);
993
- color: #424db6;
994
- }
995
- .playback-segment.active {
996
- border-color: var(--accent-strong);
997
- background: var(--accent);
998
- color: #ffffff;
999
- box-shadow: 0 8px 18px rgba(106, 110, 246, 0.32);
1000
- transform: translateY(-1px);
1001
- }
1002
- audio {
1003
- width: 100%;
1004
- margin-top: 10px;
1005
- border-radius: 8px;
1006
- background: #f8f9fe;
1007
- }
1008
- code {
1009
- padding: 2px 6px;
1010
- border-radius: 4px;
1011
- background: #eef1fb;
1012
- color: #33426a;
1013
- }
1014
- a {
1015
- color: #4a5be0;
1016
- text-decoration: none;
1017
- }
1018
- a:hover {
1019
- text-decoration: underline;
1020
- }
1021
- @media (max-width: 1120px) {
1022
- .grid {
1023
- grid-template-columns: 1fr;
1024
- }
1025
- .buttons {
1026
- grid-template-columns: 1fr;
1027
- }
1028
- .hero h1 {
1029
- font-size: 32px;
1030
- }
1031
- }
1032
- @media (max-width: 860px) {
1033
- .row {
1034
- grid-template-columns: 1fr;
1035
- }
1036
- .page {
1037
- padding: 16px 14px 20px;
1038
- }
1039
- .hero .lead {
1040
- font-size: 16px;
1041
- }
1042
- .hero h1 {
1043
- font-size: 28px;
1044
- }
1045
- .top-tabs {
1046
- gap: 14px;
1047
- }
1048
- }
1049
- </style>
1050
- </head>
1051
- <body>
1052
- <div class="page">
1053
- <div class="hero">
1054
- <h1>MOSS-TTS-Nano Demo</h1>
1055
- <p class="lead">State-of-the-art text-to-speech demo for multilingual voice cloning.</p>
1056
- <ul class="hero-points">
1057
- <li><strong>Voice Clone</strong> - Clone any voice from a reference audio.</li>
1058
- <li><strong>Voice Presets</strong> - Choose built-in demos from <code>assets/demo.jsonl</code>.</li>
1059
- </ul>
1060
- <p class="build-note">Built with <a href="https://github.com/OpenMOSS/MOSS-TTS-Nano" target="_blank" rel="noopener noreferrer">MOSS-TTS-Nano</a>.</p>
1061
- <div class="top-tabs" role="tablist" aria-label="Demo mode">
1062
- <button class="top-tab active" type="button" id="tab-voice-clone" aria-selected="true">Voice Clone</button>
1063
- <button class="top-tab" type="button" id="tab-dialogue">Multi-Speaker</button>
1064
- <a href="docs" target="_blank" class="top-tab" style="text-decoration: none; margin-left: auto; display: flex; align-items: center; gap: 4px;">
1065
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
1066
- API Docs
1067
- </a>
1068
- </div>
1069
- </div>
1070
-
1071
- <div class="grid" id="view-voice-clone">
1072
- <div class="panel input-panel">
1073
- <div class="field">
1074
- <label for="demo">Demo</label>
1075
- <select id="demo"></select>
1076
- </div>
1077
-
1078
- <div class="field">
1079
- <label for="prompt-audio-upload">Prompt Speech</label>
1080
- <div class="prompt-audio-box">
1081
- <input id="prompt-audio-upload" type="file" accept="audio/*,.wav,.mp3,.flac,.m4a,.ogg,.opus,.aac">
1082
- <audio id="prompt-audio-preview" controls hidden></audio>
1083
- <div id="prompt-audio-source" class="meta">Using the selected demo prompt speech.</div>
1084
- <div class="prompt-audio-actions">
1085
- <button id="choose-prompt-audio-btn" class="secondary" type="button" hidden>选择文件</button>
1086
- <button id="clear-prompt-audio-btn" class="secondary" type="button" hidden>使用 Demo 音频</button>
1087
- </div>
1088
- </div>
1089
- </div>
1090
-
1091
- <div class="field">
1092
- <label for="text">Text</label>
1093
- <textarea id="text" placeholder="Enter the text you want to synthesize..."></textarea>
1094
- </div>
1095
-
1096
- <details>
1097
- <summary>Generation Options</summary>
1098
- <div class="row" style="margin-top: 12px;">
1099
- <div class="field">
1100
- <label for="max-new-frames">Max New Frames</label>
1101
- <input id="max-new-frames" type="number" min="64" max="1024" step="1" value="375">
1102
- </div>
1103
- <div class="field">
1104
- <label for="voice-clone-max-text-tokens">Voice Clone Max Text Tokens</label>
1105
- <input id="voice-clone-max-text-tokens" type="number" min="25" max="200" step="1" value="75">
1106
- </div>
1107
- </div>
1108
- <div class="row">
1109
- <div class="field">
1110
- <label for="tts-max-batch-size">Max TTS Batch Size (0=auto)</label>
1111
- <input id="tts-max-batch-size" type="number" min="0" step="1" value="1">
1112
- </div>
1113
- <div class="field">
1114
- <label for="codec-max-batch-size">Max Codec Batch Size (0=auto)</label>
1115
- <input id="codec-max-batch-size" type="number" min="0" step="1" value="0">
1116
- </div>
1117
- </div>
1118
- <div class="meta">
1119
- 0 keeps the current default behavior. Set Max TTS Batch Size to 1 to force split chunks to run one by one.
1120
- Buffered generation keeps chunk order and decodes codec sub-batches no larger than the current TTS batch.
1121
- Realtime Streaming Decode keeps output order and uses the smallest active chunk-group width among auto batching, Max TTS Batch Size, and Max Codec Batch Size.
1122
- </div>
1123
- <div class="field">
1124
- <label for="cpu-thread-count">CPU Threads</label>
1125
- <input id="cpu-thread-count" type="number" min="1" step="1" value="4">
1126
- </div>
1127
- <div class="meta">
1128
- This app is CPU-only. CPU Threads maps to torch.set_num_threads for that request.
1129
- </div>
1130
- <div class="row">
1131
- <div class="field">
1132
- <label for="attn-implementation">Attention Backend</label>
1133
- <select id="attn-implementation">
1134
- <option value="model_default">model_default</option>
1135
- <option value="sdpa">sdpa</option>
1136
- <option value="eager">eager</option>
1137
- </select>
1138
- </div>
1139
- <div class="field">
1140
- <label for="seed">Seed</label>
1141
- <input id="seed" type="number" step="1" value="0">
1142
- </div>
1143
- </div>
1144
- <div class="row">
1145
- <div class="field">
1146
- <label for="text-temperature">Text Temperature</label>
1147
- <input id="text-temperature" type="number" min="0.1" max="2.0" step="0.05" value="1.0">
1148
- </div>
1149
- <div class="field">
1150
- <label for="text-top-p">Text Top P</label>
1151
- <input id="text-top-p" type="number" min="0.1" max="1.0" step="0.05" value="1.0">
1152
- </div>
1153
- </div>
1154
- <div class="row">
1155
- <div class="field">
1156
- <label for="text-top-k">Text Top K</label>
1157
- <input id="text-top-k" type="number" min="1" max="100" step="1" value="50">
1158
- </div>
1159
- <div class="field">
1160
- <label for="audio-temperature">Audio Temperature</label>
1161
- <input id="audio-temperature" type="number" min="0.1" max="2.0" step="0.05" value="0.8">
1162
- </div>
1163
- </div>
1164
- <div class="row">
1165
- <div class="field">
1166
- <label for="audio-top-p">Audio Top P</label>
1167
- <input id="audio-top-p" type="number" min="0.1" max="1.0" step="0.05" value="0.95">
1168
- </div>
1169
- <div class="field">
1170
- <label for="audio-top-k">Audio Top K</label>
1171
- <input id="audio-top-k" type="number" min="1" max="100" step="1" value="25">
1172
- </div>
1173
- </div>
1174
- <div class="row">
1175
- <div class="field">
1176
- <label for="audio-repetition-penalty">Audio Repetition Penalty</label>
1177
- <input id="audio-repetition-penalty" type="number" min="1.0" max="2.0" step="0.05" value="1.2">
1178
- </div>
1179
- <div class="field"></div>
1180
- </div>
1181
- <div class="field">
1182
- <label><input id="do-sample" type="checkbox" checked> Do Sample</label>
1183
- </div>
1184
- <div class="field">
1185
- <label><input id="enable-text-normalization" type="checkbox" checked> Enable WeTextProcessing</label>
1186
- </div>
1187
- <div class="field">
1188
- <label><input id="enable-robust-text-normalization" type="checkbox" checked> Enable normalize_tts_text</label>
1189
- </div>
1190
- <div class="meta">
1191
- WeTextProcessing and normalize_tts_text can now be toggled independently for each request.
1192
- WeTextProcessing is preloaded during startup so enabling it does not add first-request graph-build latency.
1193
- </div>
1194
- <div class="row">
1195
- <div class="field">
1196
- <label><input id="realtime-stream" type="checkbox" checked> Realtime Streaming Decode</label>
1197
- </div>
1198
- <div class="field">
1199
- <label for="initial-playback-delay-seconds">Initial Playback Delay (s)</label>
1200
- <input id="initial-playback-delay-seconds" type="number" min="0.00" step="0.01" value="0.08">
1201
- </div>
1202
- </div>
1203
- </details>
1204
-
1205
- <div class="buttons">
1206
- <button id="generate-btn" type="button">Generate</button>
1207
- <button id="pause-btn" class="secondary" type="button" disabled>Pause Playback</button>
1208
- <button id="refresh-btn" class="secondary" type="button" style="display:none;">Refresh Warmup Status</button>
1209
- </div>
1210
- </div>
1211
-
1212
- <div class="panel output-panel">
1213
- <div class="field" style="display:none;">
1214
- <label class="field-tag">Warmup Status</label>
1215
- <div id="warmup-status" class="status">__WARMUP_STATUS__</div>
1216
- </div>
1217
- <div class="field" style="display:none;">
1218
- <label class="field-tag">Text Normalization Status</label>
1219
- <div id="text-normalization-status" class="status">__TEXT_NORMALIZATION_STATUS__</div>
1220
- </div>
1221
- <div class="field">
1222
- <label class="field-tag">Run Status</label>
1223
- <div id="run-status" class="status">Idle.</div>
1224
- </div>
1225
- <div id="stream-metrics" class="meta"></div>
1226
- <div class="field" style="display:none;">
1227
- <label class="field-tag">Normalized Text</label>
1228
- <textarea id="normalized-text-output" readonly style="min-height: 120px;"></textarea>
1229
- </div>
1230
- <div class="field">
1231
- <label class="field-tag">Playback Script</label>
1232
- <div id="playback-script" class="playback-script empty">The current sentence will be highlighted here during playback.</div>
1233
- </div>
1234
- <div class="field">
1235
- <label class="field-tag">Generated Speech</label>
1236
- <div id="resolved-prompt" class="meta"></div>
1237
- </div>
1238
- <audio id="audio-output" controls></audio>
1239
- <div class="meta">Checkpoint: __CHECKPOINT__</div>
1240
- <div class="meta">Audio Tokenizer: __AUDIO_TOKENIZER__</div>
1241
- </div>
1242
- </div>
1243
-
1244
- <!-- Dialogue View -->
1245
- <div class="grid" id="view-dialogue" style="display: none; grid-template-columns: minmax(0, 1.08fr) minmax(320px, 0.92fr);">
1246
- <div class="panel">
1247
- <h3 style="margin-top:0; color:#1e293b; font-size:18px;">Dialogue Editor</h3>
1248
- <div id="dialogue-blocks-container" style="display:flex; flex-direction:column; gap: 16px;"></div>
1249
- <button id="add-dialogue-block-btn" class="secondary" style="margin-top: 16px; width: 100%; border-style: dashed;">+ Add Line</button>
1250
-
1251
- <div class="buttons" style="margin-top: 24px;">
1252
- <button id="generate-dialogue-btn" type="button" style="width: 100%;">Generate Dialogue</button>
1253
- </div>
1254
- </div>
1255
- <div class="panel">
1256
- <h3 style="margin-top:0; color:#1e293b; font-size:18px;">Speaker Settings</h3>
1257
- <div id="speakers-container" style="display:flex; flex-direction:column; gap: 12px;"></div>
1258
- <button id="add-speaker-btn" class="secondary" style="margin-top: 12px; width: 100%; font-size: 13px; border-style: dashed;">+ Add Speaker</button>
1259
-
1260
- <hr style="border: 0; border-top: 1px solid var(--line); margin: 24px 0;">
1261
- <h3 style="margin-top:0; color:#1e293b; font-size:18px;">Output</h3>
1262
- <div id="dialogue-run-status" class="status" style="margin-bottom: 12px;">Idle.</div>
1263
- <audio id="dialogue-audio-output" controls style="width: 100%; display: none;"></audio>
1264
- </div>
1265
- </div>
1266
- </div>
1267
-
1268
- <script>
1269
- const APP_BASE = __APP_BASE__;
1270
- const DEMOS = __DEMOS__;
1271
- const DEFAULT_DEMO_ID = __DEFAULT_DEMO_ID__;
1272
- const DEFAULT_ATTN_IMPLEMENTATION = __DEFAULT_ATTN_IMPLEMENTATION__;
1273
- const DEFAULT_CPU_THREADS = __DEFAULT_CPU_THREADS__;
1274
-
1275
- // ==========================================
1276
- // Multi-Speaker Dialogue Logic
1277
- // ==========================================
1278
- const tabVoiceClone = document.getElementById("tab-voice-clone");
1279
- const tabDialogue = document.getElementById("tab-dialogue");
1280
- const viewVoiceClone = document.getElementById("view-voice-clone");
1281
- const viewDialogue = document.getElementById("view-dialogue");
1282
-
1283
- if (tabVoiceClone && tabDialogue) {
1284
- tabVoiceClone.addEventListener("click", () => {
1285
- tabVoiceClone.classList.add("active");
1286
- tabDialogue.classList.remove("active");
1287
- viewVoiceClone.style.display = "grid";
1288
- viewDialogue.style.display = "none";
1289
- });
1290
- tabDialogue.addEventListener("click", () => {
1291
- tabDialogue.classList.add("active");
1292
- tabVoiceClone.classList.remove("active");
1293
- viewDialogue.style.display = "grid";
1294
- viewVoiceClone.style.display = "none";
1295
- renderDialogueUI();
1296
- });
1297
- }
1298
-
1299
- let dialogueSpeakers = [
1300
- { id: 1, name: "Speaker 1", demoId: DEMOS.length > 0 ? DEMOS[0].id : null },
1301
- { id: 2, name: "Speaker 2", demoId: DEMOS.length > 1 ? DEMOS[1].id : (DEMOS.length > 0 ? DEMOS[0].id : null) }
1302
- ];
1303
- let dialogueBlocks = [
1304
- { id: 1, speakerId: 1, text: "[shouting] Halt, traveler! The northern pass is sealed." },
1305
- { id: 2, speakerId: 2, text: "[determination] I carry a message. Step aside." }
1306
- ];
1307
- let speakerIdCounter = 3;
1308
- let blockIdCounter = 3;
1309
-
1310
- function renderDialogueUI() {
1311
- const speakersContainer = document.getElementById("speakers-container");
1312
- const blocksContainer = document.getElementById("dialogue-blocks-container");
1313
-
1314
- // Render Speakers
1315
- speakersContainer.innerHTML = "";
1316
- dialogueSpeakers.forEach((spk, index) => {
1317
- const div = document.createElement("div");
1318
- div.style.cssText = "padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: #f8fafc;";
1319
-
1320
- let optionsHtml = DEMOS.map(d => `<option value="${d.id}" ${spk.demoId === d.id ? 'selected' : ''}>${d.name}</option>`).join('');
1321
-
1322
- div.innerHTML = `
1323
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
1324
- <input type="text" value="${spk.name}" onchange="updateSpeakerName(${spk.id}, this.value)" style="font-weight: 700; border: none; background: transparent; padding: 0; margin: 0; color: var(--accent-strong); font-size: 14px; outline: none; width: 120px;">
1325
- ${dialogueSpeakers.length > 1 ? `<button onclick="removeSpeaker(${spk.id})" style="background:transparent; border:none; color:var(--danger); padding:0; box-shadow:none; font-size: 12px;">Remove</button>` : ''}
1326
- </div>
1327
- <div class="field" style="margin: 0;">
1328
- <select onchange="updateSpeakerVoice(${spk.id}, this.value)" style="padding: 6px 8px; font-size: 13px;">
1329
- ${optionsHtml}
1330
- </select>
1331
- </div>
1332
- `;
1333
- speakersContainer.appendChild(div);
1334
- });
1335
-
1336
- // Render Blocks
1337
- blocksContainer.innerHTML = "";
1338
- dialogueBlocks.forEach((blk, index) => {
1339
- const div = document.createElement("div");
1340
- div.style.cssText = "border: 1px solid var(--line); border-radius: 12px; overflow: hidden; background: #fff;";
1341
-
1342
- let spkOptions = dialogueSpeakers.map(s => `<option value="${s.id}" ${blk.speakerId === s.id ? 'selected' : ''}>👤 ${s.name}</option>`).join('');
1343
-
1344
- div.innerHTML = `
1345
- <div style="background: #f1f5f9; padding: 8px 12px; border-bottom: 1px solid var(--line); display: flex; justify-content: space-between; align-items: center;">
1346
- <select onchange="updateBlockSpeaker(${blk.id}, this.value)" style="border:none; background:transparent; font-weight: 700; font-size: 13px; color: #334155; outline: none; padding: 0;">
1347
- ${spkOptions}
1348
- </select>
1349
- <button onclick="removeBlock(${blk.id})" style="background:transparent; border:none; color:var(--muted); padding:0; box-shadow:none; font-size: 12px; cursor: pointer;">✕</button>
1350
- </div>
1351
- <div style="padding: 12px;">
1352
- <textarea onchange="updateBlockText(${blk.id}, this.value)" placeholder="Enter dialogue line here..." style="width: 100%; border: none; resize: vertical; min-height: 60px; outline: none; font-size: 14px; padding: 0; box-shadow: none;">${blk.text}</textarea>
1353
- </div>
1354
- `;
1355
- blocksContainer.appendChild(div);
1356
- });
1357
- }
1358
-
1359
- window.updateSpeakerName = (id, name) => { const s = dialogueSpeakers.find(x => x.id === id); if(s) s.name = name; renderDialogueUI(); };
1360
- window.updateSpeakerVoice = (id, voice) => { const s = dialogueSpeakers.find(x => x.id === id); if(s) s.demoId = voice; };
1361
- window.removeSpeaker = (id) => { dialogueSpeakers = dialogueSpeakers.filter(x => x.id !== id); renderDialogueUI(); };
1362
- window.updateBlockSpeaker = (id, spkId) => { const b = dialogueBlocks.find(x => x.id === id); if(b) b.speakerId = parseInt(spkId); };
1363
- window.updateBlockText = (id, text) => { const b = dialogueBlocks.find(x => x.id === id); if(b) b.text = text; };
1364
- window.removeBlock = (id) => { dialogueBlocks = dialogueBlocks.filter(x => x.id !== id); renderDialogueUI(); };
1365
-
1366
- const addSpeakerBtn = document.getElementById("add-speaker-btn");
1367
- if(addSpeakerBtn) {
1368
- addSpeakerBtn.addEventListener("click", () => {
1369
- dialogueSpeakers.push({ id: speakerIdCounter++, name: "Speaker " + speakerIdCounter, demoId: DEMOS[0]?.id });
1370
- renderDialogueUI();
1371
- });
1372
- }
1373
-
1374
- const addBlockBtn = document.getElementById("add-dialogue-block-btn");
1375
- if(addBlockBtn) {
1376
- addBlockBtn.addEventListener("click", () => {
1377
- dialogueBlocks.push({ id: blockIdCounter++, speakerId: dialogueSpeakers[0]?.id || 1, text: "" });
1378
- renderDialogueUI();
1379
- });
1380
- }
1381
-
1382
- const genBtn = document.getElementById("generate-dialogue-btn");
1383
- if(genBtn) {
1384
- genBtn.addEventListener("click", async () => {
1385
- if(dialogueBlocks.length === 0) return;
1386
- genBtn.disabled = true;
1387
- const statusDiv = document.getElementById("dialogue-run-status");
1388
- const audioOut = document.getElementById("dialogue-audio-output");
1389
- audioOut.style.display = "none";
1390
- audioOut.removeAttribute("src");
1391
-
1392
- statusDiv.textContent = "Generating...";
1393
- statusDiv.classList.remove("error");
1394
-
1395
- let pcmChunks = [];
1396
- let currentSampleRate = null;
1397
- let currentChannels = null;
1398
-
1399
- try {
1400
- for (let i = 0; i < dialogueBlocks.length; i++) {
1401
- const blk = dialogueBlocks[i];
1402
- if(!blk.text.trim()) continue;
1403
-
1404
- const spk = dialogueSpeakers.find(s => s.id === blk.speakerId);
1405
- if(!spk) continue;
1406
-
1407
- statusDiv.textContent = `Generating Block ${i+1}/${dialogueBlocks.length} (${spk.name})...`;
1408
-
1409
- const fd = new FormData();
1410
- fd.append("text", blk.text);
1411
- fd.append("demo_id", spk.demoId);
1412
- // Append default params
1413
- fd.append("max_new_frames", "1024");
1414
- fd.append("voice_clone_max_text_tokens", "75");
1415
-
1416
- const res = await fetch(`${APP_BASE}/api/generate`, { method: "POST", body: fd });
1417
- if(!res.ok) throw new Error(await res.text());
1418
- const data = await res.json();
1419
-
1420
- if(!currentSampleRate) currentSampleRate = data.sample_rate;
1421
-
1422
- // Decode base64 wav
1423
- const binary = atob(data.audio_base64);
1424
- const bytes = new Uint8Array(binary.length);
1425
- for(let j=0; j<binary.length; j++) bytes[j] = binary.charCodeAt(j);
1426
-
1427
- // Slice out WAV header (assume standard 44 bytes)
1428
- pcmChunks.push(bytes.slice(44));
1429
-
1430
- // If we need the header for the final audio
1431
- if(i === 0) {
1432
- window._wavHeaderTpl = bytes.slice(0, 44);
1433
- }
1434
- }
1435
-
1436
- if(pcmChunks.length > 0) {
1437
- statusDiv.textContent = `Stitching audio...`;
1438
- let totalPcmLen = pcmChunks.reduce((acc, val) => acc + val.length, 0);
1439
- let finalWav = new Uint8Array(44 + totalPcmLen);
1440
- finalWav.set(window._wavHeaderTpl, 0);
1441
-
1442
- // Update lengths in header
1443
- const dataView = new DataView(finalWav.buffer);
1444
- dataView.setUint32(40, totalPcmLen, true);
1445
- dataView.setUint32(4, 36 + totalPcmLen, true);
1446
-
1447
- let offset = 44;
1448
- for(let chunk of pcmChunks) {
1449
- finalWav.set(chunk, offset);
1450
- offset += chunk.length;
1451
- }
1452
-
1453
- const blob = new Blob([finalWav], { type: "audio/wav" });
1454
- audioOut.src = URL.createObjectURL(blob);
1455
- audioOut.style.display = "block";
1456
- statusDiv.textContent = "Done!";
1457
- } else {
1458
- statusDiv.textContent = "No valid text to generate.";
1459
- }
1460
-
1461
- } catch(e) {
1462
- statusDiv.textContent = `Error: ${e.message}`;
1463
- statusDiv.classList.add("error");
1464
- } finally {
1465
- genBtn.disabled = false;
1466
- }
1467
- });
1468
- }
1469
-
1470
- // ==========================================
1471
-
1472
-
1473
- const demoSelect = document.getElementById("demo");
1474
- const promptAudioUploadInput = document.getElementById("prompt-audio-upload");
1475
- const promptAudioPreview = document.getElementById("prompt-audio-preview");
1476
- const promptAudioSource = document.getElementById("prompt-audio-source");
1477
- const choosePromptAudioBtn = document.getElementById("choose-prompt-audio-btn");
1478
- const clearPromptAudioBtn = document.getElementById("clear-prompt-audio-btn");
1479
- const warmupStatus = document.getElementById("warmup-status");
1480
- const textNormalizationStatus = document.getElementById("text-normalization-status");
1481
- const runStatus = document.getElementById("run-status");
1482
- const streamMetrics = document.getElementById("stream-metrics");
1483
- const textInput = document.getElementById("text");
1484
- const normalizedTextOutput = document.getElementById("normalized-text-output");
1485
- const playbackScript = document.getElementById("playback-script");
1486
- const resolvedPrompt = document.getElementById("resolved-prompt");
1487
- const audioOutput = document.getElementById("audio-output");
1488
- const generateBtn = document.getElementById("generate-btn");
1489
- const pauseBtn = document.getElementById("pause-btn");
1490
- const refreshBtn = document.getElementById("refresh-btn");
1491
- const realtimeStreamToggle = document.getElementById("realtime-stream");
1492
- const initialPlaybackDelayInput = document.getElementById("initial-playback-delay-seconds");
1493
-
1494
- let currentAudioObjectUrl = null;
1495
- let currentStreamId = null;
1496
- let currentStreamAbortController = null;
1497
- let currentStreamStatusTimer = null;
1498
- let currentAudioContext = null;
1499
- let nextPlaybackTime = 0;
1500
- let currentInitialPlaybackDelaySeconds = 0.08;
1501
- let currentRealtimePlaybackPaused = false;
1502
- let currentRealtimePlaybackCompletionTimer = null;
1503
- let playbackChunks = [];
1504
- let bufferedPlaybackBoundaries = [];
1505
- let currentPlaybackChunkIndex = null;
1506
- let currentPlaybackMarkedComplete = false;
1507
- let currentPromptAudioPreviewUrl = null;
1508
- let currentRealtimePlaybackStartAt = null;
1509
- let currentRealtimePlaybackScheduledAudioSeconds = 0;
1510
- let currentRealtimePlaybackChunkRanges = [];
1511
-
1512
- const demosById = new Map();
1513
- for (const demo of DEMOS) {
1514
- demosById.set(demo.id, demo);
1515
- const option = document.createElement("option");
1516
- option.value = demo.id;
1517
- option.textContent = demo.name;
1518
- if (demo.id === DEFAULT_DEMO_ID) {
1519
- option.selected = true;
1520
- }
1521
- demoSelect.appendChild(option);
1522
- }
1523
- document.getElementById("attn-implementation").value = DEFAULT_ATTN_IMPLEMENTATION;
1524
-
1525
- function getSelectedDemo() {
1526
- return demosById.get(demoSelect.value) || DEMOS[0] || null;
1527
- }
1528
-
1529
- function getUploadedPromptAudioFile() {
1530
- const files = promptAudioUploadInput.files;
1531
- return files && files.length > 0 ? files[0] : null;
1532
- }
1533
-
1534
- function clearPromptAudioPreviewUrl() {
1535
- if (!currentPromptAudioPreviewUrl) {
1536
- return;
1537
- }
1538
- URL.revokeObjectURL(currentPromptAudioPreviewUrl);
1539
- currentPromptAudioPreviewUrl = null;
1540
- }
1541
-
1542
- function getDemoPromptAudioUrl(demoId) {
1543
- return `${APP_BASE}/api/demo-prompt-audio/${encodeURIComponent(demoId)}`;
1544
- }
1545
-
1546
- function showPromptAudioFilePicker(message = "选择文件 | 未选择任何文件") {
1547
- promptAudioPreview.pause();
1548
- promptAudioPreview.removeAttribute("src");
1549
- promptAudioPreview.load();
1550
- promptAudioPreview.hidden = true;
1551
- promptAudioUploadInput.hidden = false;
1552
- choosePromptAudioBtn.hidden = true;
1553
- clearPromptAudioBtn.hidden = true;
1554
- promptAudioSource.textContent = message;
1555
- }
1556
-
1557
- function showPromptAudioPreview({
1558
- sourceUrl,
1559
- message,
1560
- showResetToDemo,
1561
- }) {
1562
- promptAudioPreview.src = sourceUrl;
1563
- promptAudioPreview.hidden = false;
1564
- promptAudioUploadInput.hidden = true;
1565
- choosePromptAudioBtn.hidden = false;
1566
- clearPromptAudioBtn.hidden = !showResetToDemo;
1567
- promptAudioSource.textContent = message;
1568
- }
1569
-
1570
- function updatePromptAudioPanel(demo = getSelectedDemo()) {
1571
- const uploadedPromptAudio = getUploadedPromptAudioFile();
1572
- if (uploadedPromptAudio) {
1573
- clearPromptAudioPreviewUrl();
1574
- currentPromptAudioPreviewUrl = URL.createObjectURL(uploadedPromptAudio);
1575
- showPromptAudioPreview({
1576
- sourceUrl: currentPromptAudioPreviewUrl,
1577
- message: `Using uploaded prompt speech: ${uploadedPromptAudio.name}`,
1578
- showResetToDemo: true,
1579
- });
1580
- return;
1581
- }
1582
-
1583
- clearPromptAudioPreviewUrl();
1584
-
1585
- if (demo && demo.id) {
1586
- showPromptAudioPreview({
1587
- sourceUrl: getDemoPromptAudioUrl(demo.id),
1588
- message: demo.prompt_speech
1589
- ? `Using demo prompt speech: ${demo.prompt_speech}`
1590
- : "Using demo prompt speech.",
1591
- showResetToDemo: false,
1592
- });
1593
- return;
1594
- }
1595
-
1596
- showPromptAudioFilePicker();
1597
- }
1598
-
1599
- function applySelectedDemo(replaceText = true) {
1600
- const demo = getSelectedDemo();
1601
- const uploadedPromptAudio = getUploadedPromptAudioFile();
1602
-
1603
- if (!demo && !uploadedPromptAudio) {
1604
- resolvedPrompt.textContent = "";
1605
- if (replaceText) {
1606
- textInput.value = "";
1607
- }
1608
- updatePromptAudioPanel(null);
1609
- renderPlaybackScript([], textInput.value);
1610
- generateBtn.disabled = true;
1611
- return;
1612
- }
1613
-
1614
- if (demo && replaceText) {
1615
- textInput.value = demo.text || "";
1616
- }
1617
- updatePromptAudioPanel(demo);
1618
- previewPlaybackScriptFromInputs();
1619
- generateBtn.disabled = false;
1620
- }
1621
-
1622
- function setStatus(node, text, isError = false) {
1623
- node.textContent = text;
1624
- node.classList.toggle("error", Boolean(isError));
1625
- }
1626
-
1627
- function clearAudioOutput() {
1628
- if (currentAudioObjectUrl) {
1629
- URL.revokeObjectURL(currentAudioObjectUrl);
1630
- currentAudioObjectUrl = null;
1631
- }
1632
- audioOutput.pause();
1633
- audioOutput.removeAttribute("src");
1634
- audioOutput.load();
1635
- bufferedPlaybackBoundaries = [];
1636
- currentPlaybackMarkedComplete = false;
1637
- setPlaybackHighlight(null);
1638
- updatePauseButtonState();
1639
- }
1640
-
1641
- function clearNormalizedOutputs() {
1642
- normalizedTextOutput.value = "";
1643
- }
1644
-
1645
- function updateNormalizedOutputs(payload) {
1646
- normalizedTextOutput.value = payload.normalized_text || "";
1647
- }
1648
-
1649
- function splitTextForDisplay(text) {
1650
- const normalizedText = String(text || "").trim();
1651
- if (!normalizedText) {
1652
- return [];
1653
- }
1654
-
1655
- const segments = [];
1656
- for (const rawLine of normalizedText.split(/\\n+/)) {
1657
- const line = rawLine.trim();
1658
- if (!line) {
1659
- continue;
1660
- }
1661
- const parts = line.match(/[^。!?!?;;.\\n]+(?:[。!?!?;;]+|\\.(?=\\s|$)|$)/g);
1662
- if (parts && parts.length > 1) {
1663
- for (const part of parts) {
1664
- const cleanedPart = part.trim();
1665
- if (cleanedPart) {
1666
- segments.push(cleanedPart);
1667
- }
1668
- }
1669
- continue;
1670
- }
1671
- segments.push(line);
1672
- }
1673
- return segments.length ? segments : [normalizedText];
1674
- }
1675
-
1676
- function normalizePlaybackChunks(chunks, fallbackText = "") {
1677
- if (Array.isArray(chunks)) {
1678
- const normalizedChunks = chunks
1679
- .map((chunk) => String(chunk || "").trim())
1680
- .filter(Boolean);
1681
- if (normalizedChunks.length > 0) {
1682
- return normalizedChunks;
1683
- }
1684
- }
1685
- return splitTextForDisplay(fallbackText);
1686
- }
1687
-
1688
- function rebuildBufferedPlaybackBoundaries() {
1689
- bufferedPlaybackBoundaries = [];
1690
- if (!playbackChunks.length) {
1691
- return;
1692
- }
1693
- const duration = Number(audioOutput.duration);
1694
- if (!Number.isFinite(duration) || duration <= 0) {
1695
- return;
1696
- }
1697
-
1698
- const weights = playbackChunks.map((chunk) => {
1699
- const compactChunk = String(chunk).replace(/\\s+/g, "");
1700
- return Math.max(1, Array.from(compactChunk).length);
1701
- });
1702
- const totalWeight = weights.reduce((sum, value) => sum + value, 0);
1703
- let accumulatedSeconds = 0;
1704
- bufferedPlaybackBoundaries = weights.map((weight) => {
1705
- accumulatedSeconds += (duration * weight) / totalWeight;
1706
- return accumulatedSeconds;
1707
- });
1708
- }
1709
-
1710
- function setPlaybackHighlight(activeIndex, options = {}) {
1711
- const markAllPlayed = Boolean(options.markAllPlayed);
1712
- const segmentNodes = playbackScript.querySelectorAll(".playback-segment");
1713
- let normalizedActiveIndex = null;
1714
- if (segmentNodes.length > 0 && activeIndex !== null && activeIndex !== undefined) {
1715
- const numericIndex = Number(activeIndex);
1716
- if (Number.isFinite(numericIndex)) {
1717
- normalizedActiveIndex = Math.max(0, Math.min(segmentNodes.length - 1, Math.trunc(numericIndex)));
1718
- }
1719
- }
1720
-
1721
- const shouldScroll =
1722
- normalizedActiveIndex !== null &&
1723
- normalizedActiveIndex !== currentPlaybackChunkIndex &&
1724
- !markAllPlayed;
1725
-
1726
- segmentNodes.forEach((node, index) => {
1727
- const isActive = normalizedActiveIndex !== null && index === normalizedActiveIndex && !markAllPlayed;
1728
- const isPlayed = markAllPlayed || (normalizedActiveIndex !== null && index < normalizedActiveIndex);
1729
- node.classList.toggle("active", isActive);
1730
- node.classList.toggle("played", isPlayed);
1731
- });
1732
-
1733
- if (shouldScroll) {
1734
- segmentNodes[normalizedActiveIndex].scrollIntoView({
1735
- block: "nearest",
1736
- inline: "nearest",
1737
- behavior: "smooth"
1738
- });
1739
- }
1740
-
1741
- currentPlaybackChunkIndex = normalizedActiveIndex;
1742
- currentPlaybackMarkedComplete = markAllPlayed;
1743
- }
1744
-
1745
- function renderPlaybackScript(chunks, fallbackText = "") {
1746
- playbackChunks = normalizePlaybackChunks(chunks, fallbackText);
1747
- bufferedPlaybackBoundaries = [];
1748
- currentPlaybackChunkIndex = null;
1749
- currentPlaybackMarkedComplete = false;
1750
- playbackScript.innerHTML = "";
1751
-
1752
- if (!playbackChunks.length) {
1753
- playbackScript.classList.add("empty");
1754
- playbackScript.textContent = "The current sentence will be highlighted here during playback.";
1755
- return;
1756
- }
1757
-
1758
- playbackScript.classList.remove("empty");
1759
- for (const chunk of playbackChunks) {
1760
- const segmentNode = document.createElement("span");
1761
- segmentNode.className = "playback-segment";
1762
- segmentNode.textContent = chunk;
1763
- playbackScript.appendChild(segmentNode);
1764
- }
1765
- rebuildBufferedPlaybackBoundaries();
1766
- setPlaybackHighlight(null);
1767
- }
1768
-
1769
- function updateBufferedPlaybackHighlight() {
1770
- if (hasRealtimePlayback()) {
1771
- return;
1772
- }
1773
- if (!hasBufferedPlayback() || !playbackChunks.length || currentPlaybackMarkedComplete) {
1774
- return;
1775
- }
1776
- if (audioOutput.ended) {
1777
- setPlaybackHighlight(null, { markAllPlayed: true });
1778
- return;
1779
- }
1780
- if (!Number.isFinite(audioOutput.duration) || audioOutput.duration <= 0) {
1781
- if (!audioOutput.paused) {
1782
- setPlaybackHighlight(0);
1783
- }
1784
- return;
1785
- }
1786
- if (!bufferedPlaybackBoundaries.length) {
1787
- rebuildBufferedPlaybackBoundaries();
1788
- }
1789
- if (!bufferedPlaybackBoundaries.length) {
1790
- return;
1791
- }
1792
-
1793
- const clampedCurrentTime = Math.max(0, Math.min(Number(audioOutput.currentTime || 0), Number(audioOutput.duration)));
1794
- let activeIndex = bufferedPlaybackBoundaries.findIndex((boundary) => clampedCurrentTime <= boundary);
1795
- if (activeIndex < 0) {
1796
- activeIndex = playbackChunks.length - 1;
1797
- }
1798
- setPlaybackHighlight(activeIndex);
1799
- }
1800
-
1801
- function previewPlaybackScriptFromInputs() {
1802
- if (hasBufferedPlayback() || hasRealtimePlayback()) {
1803
- return;
1804
- }
1805
- renderPlaybackScript([], textInput.value);
1806
- }
1807
-
1808
- function hasBufferedPlayback() {
1809
- return Boolean(audioOutput.getAttribute("src"));
1810
- }
1811
-
1812
- function hasRealtimePlayback() {
1813
- return Boolean(currentAudioContext);
1814
- }
1815
-
1816
- function updatePauseButtonState() {
1817
- if (hasRealtimePlayback()) {
1818
- pauseBtn.disabled = false;
1819
- pauseBtn.textContent = currentRealtimePlaybackPaused ? "Resume Playback" : "Pause Playback";
1820
- return;
1821
- }
1822
- if (hasBufferedPlayback()) {
1823
- pauseBtn.disabled = false;
1824
- pauseBtn.textContent = audioOutput.paused ? "Resume Playback" : "Pause Playback";
1825
- return;
1826
- }
1827
- pauseBtn.disabled = true;
1828
- pauseBtn.textContent = "Pause Playback";
1829
- }
1830
-
1831
- function resetRealtimePlaybackTracking() {
1832
- currentRealtimePlaybackStartAt = null;
1833
- currentRealtimePlaybackScheduledAudioSeconds = 0;
1834
- currentRealtimePlaybackChunkRanges = [];
1835
- }
1836
-
1837
- function normalizeRealtimeChunkRanges(rawRanges) {
1838
- if (!Array.isArray(rawRanges)) {
1839
- return [];
1840
- }
1841
- const normalizedRanges = [];
1842
- for (const rawRange of rawRanges) {
1843
- if (!Array.isArray(rawRange) || rawRange.length < 3) {
1844
- continue;
1845
- }
1846
- const startSeconds = Number(rawRange[0]);
1847
- const endSeconds = Number(rawRange[1]);
1848
- const chunkIndex = Number(rawRange[2]);
1849
- if (!Number.isFinite(startSeconds) || !Number.isFinite(endSeconds) || !Number.isFinite(chunkIndex)) {
1850
- continue;
1851
- }
1852
- normalizedRanges.push([
1853
- Math.max(0, startSeconds),
1854
- Math.max(0, endSeconds),
1855
- Math.max(0, Math.trunc(chunkIndex))
1856
- ]);
1857
- }
1858
- normalizedRanges.sort((a, b) => a[1] - b[1]);
1859
- return normalizedRanges;
1860
- }
1861
-
1862
- function resolveRealtimeChunkIndexByPlaybackSeconds(playbackSeconds) {
1863
- if (!currentRealtimePlaybackChunkRanges.length) {
1864
- return null;
1865
- }
1866
- const clampedSeconds = Math.max(0, Number(playbackSeconds) || 0);
1867
- for (const [, endSeconds, chunkIndex] of currentRealtimePlaybackChunkRanges) {
1868
- if (clampedSeconds <= endSeconds + 1e-6) {
1869
- return chunkIndex;
1870
- }
1871
- }
1872
- return currentRealtimePlaybackChunkRanges[currentRealtimePlaybackChunkRanges.length - 1][2];
1873
- }
1874
-
1875
- function updateRealtimePlaybackHighlightFromLocalClock() {
1876
- if (!currentAudioContext || currentRealtimePlaybackPaused) {
1877
- return;
1878
- }
1879
- if (currentRealtimePlaybackStartAt === null || !currentRealtimePlaybackChunkRanges.length) {
1880
- return;
1881
- }
1882
- const elapsedPlaybackSeconds = Math.max(0, currentAudioContext.currentTime - currentRealtimePlaybackStartAt);
1883
- const clampedPlaybackSeconds = Math.min(
1884
- elapsedPlaybackSeconds,
1885
- currentRealtimePlaybackScheduledAudioSeconds > 0
1886
- ? currentRealtimePlaybackScheduledAudioSeconds
1887
- : elapsedPlaybackSeconds
1888
- );
1889
- const chunkIndex = resolveRealtimeChunkIndexByPlaybackSeconds(clampedPlaybackSeconds);
1890
- if (chunkIndex !== null && chunkIndex !== undefined) {
1891
- setPlaybackHighlight(chunkIndex);
1892
- }
1893
- }
1894
-
1895
- function base64ToBlob(base64Value, mimeType) {
1896
- const binary = atob(base64Value);
1897
- const bytes = new Uint8Array(binary.length);
1898
- for (let i = 0; i < binary.length; i += 1) {
1899
- bytes[i] = binary.charCodeAt(i);
1900
- }
1901
- return new Blob([bytes], { type: mimeType });
1902
- }
1903
-
1904
- async function fetchJson(url, options) {
1905
- const response = await fetch(url, options);
1906
- const text = await response.text();
1907
- if (!response.ok) {
1908
- throw new Error(text || `HTTP ${response.status}`);
1909
- }
1910
- return text ? JSON.parse(text) : {};
1911
- }
1912
-
1913
- function buildFormData() {
1914
- const demo = getSelectedDemo();
1915
- const uploadedPromptAudio = getUploadedPromptAudioFile();
1916
- const formData = new FormData();
1917
- formData.append("text", textInput.value);
1918
- if (demo) {
1919
- formData.append("demo_id", demo.id);
1920
- }
1921
- if (uploadedPromptAudio) {
1922
- formData.append("prompt_audio", uploadedPromptAudio);
1923
- }
1924
- formData.append("max_new_frames", document.getElementById("max-new-frames").value);
1925
- formData.append("voice_clone_max_text_tokens", document.getElementById("voice-clone-max-text-tokens").value);
1926
- formData.append("attn_implementation", document.getElementById("attn-implementation").value);
1927
- formData.append("do_sample", document.getElementById("do-sample").checked ? "1" : "0");
1928
- formData.append("text_temperature", document.getElementById("text-temperature").value);
1929
- formData.append("text_top_p", document.getElementById("text-top-p").value);
1930
- formData.append("text_top_k", document.getElementById("text-top-k").value);
1931
- formData.append("audio_temperature", document.getElementById("audio-temperature").value);
1932
- formData.append("audio_top_p", document.getElementById("audio-top-p").value);
1933
- formData.append("audio_top_k", document.getElementById("audio-top-k").value);
1934
- formData.append("audio_repetition_penalty", document.getElementById("audio-repetition-penalty").value);
1935
- formData.append("seed", document.getElementById("seed").value);
1936
- formData.append("tts_max_batch_size", document.getElementById("tts-max-batch-size").value);
1937
- formData.append("codec_max_batch_size", document.getElementById("codec-max-batch-size").value);
1938
- formData.append("enable_text_normalization", document.getElementById("enable-text-normalization").checked ? "1" : "0");
1939
- formData.append("enable_normalize_tts_text", document.getElementById("enable-robust-text-normalization").checked ? "1" : "0");
1940
- formData.append("cpu_threads", document.getElementById("cpu-thread-count").value || String(DEFAULT_CPU_THREADS));
1941
- return formData;
1942
- }
1943
-
1944
- function mergeUint8Arrays(a, b) {
1945
- const merged = new Uint8Array(a.length + b.length);
1946
- merged.set(a, 0);
1947
- merged.set(b, a.length);
1948
- return merged;
1949
- }
1950
-
1951
- function resolveInitialPlaybackDelaySeconds() {
1952
- const rawValue = Number(initialPlaybackDelayInput.value);
1953
- if (!Number.isFinite(rawValue)) {
1954
- return 0.08;
1955
- }
1956
- return Math.max(0.0, rawValue);
1957
- }
1958
-
1959
- function schedulePcmChunk(pcmChunk, sampleRate, channels) {
1960
- if (!currentAudioContext || pcmChunk.byteLength <= 0) {
1961
- return;
1962
- }
1963
- const bytesPerFrame = channels * 2;
1964
- const totalFrames = Math.floor(pcmChunk.byteLength / bytesPerFrame);
1965
- if (totalFrames <= 0) {
1966
- return;
1967
- }
1968
-
1969
- const audioBuffer = currentAudioContext.createBuffer(channels, totalFrames, sampleRate);
1970
- const view = new DataView(pcmChunk.buffer, pcmChunk.byteOffset, totalFrames * bytesPerFrame);
1971
- for (let channelIndex = 0; channelIndex < channels; channelIndex += 1) {
1972
- const channelData = audioBuffer.getChannelData(channelIndex);
1973
- for (let frameIndex = 0; frameIndex < totalFrames; frameIndex += 1) {
1974
- const byteOffset = (frameIndex * channels + channelIndex) * 2;
1975
- channelData[frameIndex] = view.getInt16(byteOffset, true) / 32768.0;
1976
- }
1977
- }
1978
-
1979
- const source = currentAudioContext.createBufferSource();
1980
- source.buffer = audioBuffer;
1981
- source.connect(currentAudioContext.destination);
1982
- const now = currentAudioContext.currentTime;
1983
- const startAt = Math.max(nextPlaybackTime || (now + currentInitialPlaybackDelaySeconds), now + 0.02);
1984
- source.start(startAt);
1985
- const endAt = startAt + audioBuffer.duration;
1986
- nextPlaybackTime = endAt;
1987
- if (currentRealtimePlaybackStartAt === null) {
1988
- currentRealtimePlaybackStartAt = startAt;
1989
- }
1990
- currentRealtimePlaybackScheduledAudioSeconds = Math.max(
1991
- currentRealtimePlaybackScheduledAudioSeconds,
1992
- endAt - currentRealtimePlaybackStartAt
1993
- );
1994
- }
1995
-
1996
- function clearRealtimePlaybackCompletionTimer() {
1997
- if (currentRealtimePlaybackCompletionTimer) {
1998
- window.clearTimeout(currentRealtimePlaybackCompletionTimer);
1999
- currentRealtimePlaybackCompletionTimer = null;
2000
- }
2001
- }
2002
-
2003
- function monitorRealtimePlaybackCompletion() {
2004
- clearRealtimePlaybackCompletionTimer();
2005
- if (!currentAudioContext) {
2006
- return;
2007
- }
2008
-
2009
- async function pollRealtimePlaybackCompletion() {
2010
- if (!currentAudioContext) {
2011
- return;
2012
- }
2013
- if (currentRealtimePlaybackPaused) {
2014
- currentRealtimePlaybackCompletionTimer = window.setTimeout(() => {
2015
- pollRealtimePlaybackCompletion().catch(() => {});
2016
- }, 120);
2017
- return;
2018
- }
2019
-
2020
- updateRealtimePlaybackHighlightFromLocalClock();
2021
-
2022
- const remainingSeconds = nextPlaybackTime - currentAudioContext.currentTime;
2023
- if (remainingSeconds > 0.05) {
2024
- currentRealtimePlaybackCompletionTimer = window.setTimeout(() => {
2025
- pollRealtimePlaybackCompletion().catch(() => {});
2026
- }, 120);
2027
- return;
2028
- }
2029
-
2030
- setPlaybackHighlight(null, { markAllPlayed: true });
2031
- try {
2032
- await currentAudioContext.close();
2033
- } catch (error) {
2034
- }
2035
- currentAudioContext = null;
2036
- currentRealtimePlaybackPaused = false;
2037
- nextPlaybackTime = 0;
2038
- resetRealtimePlaybackTracking();
2039
- updatePauseButtonState();
2040
- }
2041
-
2042
- currentRealtimePlaybackCompletionTimer = window.setTimeout(() => {
2043
- pollRealtimePlaybackCompletion().catch(() => {});
2044
- }, 120);
2045
- }
2046
-
2047
- async function closeRealtimeStream() {
2048
- clearRealtimePlaybackCompletionTimer();
2049
- if (currentStreamStatusTimer) {
2050
- window.clearInterval(currentStreamStatusTimer);
2051
- currentStreamStatusTimer = null;
2052
- }
2053
- if (currentStreamAbortController) {
2054
- currentStreamAbortController.abort();
2055
- currentStreamAbortController = null;
2056
- }
2057
- if (currentStreamId) {
2058
- fetch(`${APP_BASE}/api/generate-stream/${encodeURIComponent(currentStreamId)}/close`, {
2059
- method: "POST"
2060
- }).catch(() => {});
2061
- currentStreamId = null;
2062
- }
2063
- if (currentAudioContext) {
2064
- try {
2065
- await currentAudioContext.close();
2066
- } catch (error) {
2067
- }
2068
- currentAudioContext = null;
2069
- }
2070
- nextPlaybackTime = 0;
2071
- currentRealtimePlaybackPaused = false;
2072
- resetRealtimePlaybackTracking();
2073
- currentPlaybackMarkedComplete = false;
2074
- setPlaybackHighlight(null);
2075
- updatePauseButtonState();
2076
- }
2077
-
2078
- async function refreshWarmupStatus() {
2079
- try {
2080
- const [warmupData, normalizationData] = await Promise.all([
2081
- fetchJson(`${APP_BASE}/api/warmup-status`),
2082
- fetchJson(`${APP_BASE}/api/text-normalization-status`),
2083
- ]);
2084
- setStatus(warmupStatus, warmupData.status_text || "Unknown status.");
2085
- setStatus(
2086
- textNormalizationStatus,
2087
- normalizationData.status_text || "Unknown status.",
2088
- Boolean(normalizationData.failed)
2089
- );
2090
- } catch (error) {
2091
- setStatus(warmupStatus, String(error), true);
2092
- setStatus(textNormalizationStatus, String(error), true);
2093
- }
2094
- }
2095
-
2096
- async function generateBuffered(formData) {
2097
- clearAudioOutput();
2098
- streamMetrics.textContent = "";
2099
- const data = await fetchJson(`${APP_BASE}/api/generate`, {
2100
- method: "POST",
2101
- body: formData
2102
- });
2103
-
2104
- updateNormalizedOutputs(data);
2105
- renderPlaybackScript(data.text_chunks, data.normalized_text || textInput.value);
2106
- const audioBlob = base64ToBlob(data.audio_base64, "audio/wav");
2107
- currentAudioObjectUrl = URL.createObjectURL(audioBlob);
2108
- audioOutput.src = currentAudioObjectUrl;
2109
- audioOutput.play().catch(() => {});
2110
- rebuildBufferedPlaybackBoundaries();
2111
- updateBufferedPlaybackHighlight();
2112
- updatePauseButtonState();
2113
- resolvedPrompt.textContent = "Generated speech is ready.";
2114
- setStatus(runStatus, data.run_status || "Done.");
2115
- if (data.warmup_status_text) {
2116
- setStatus(warmupStatus, data.warmup_status_text);
2117
- }
2118
- if (data.text_normalization_status_text) {
2119
- setStatus(textNormalizationStatus, data.text_normalization_status_text);
2120
- }
2121
- }
2122
-
2123
- async function generateRealtime(formData) {
2124
- await closeRealtimeStream();
2125
- clearAudioOutput();
2126
- resolvedPrompt.textContent = "Generating realtime speech...";
2127
- streamMetrics.textContent = "";
2128
-
2129
- const startData = await fetchJson(`${APP_BASE}/api/generate-stream/start`, {
2130
- method: "POST",
2131
- body: formData
2132
- });
2133
-
2134
- currentStreamId = startData.stream_id;
2135
- setStatus(runStatus, startData.run_status || "Streaming realtime audio...");
2136
- if (startData.warmup_status_text) {
2137
- setStatus(warmupStatus, startData.warmup_status_text);
2138
- }
2139
- if (startData.text_normalization_status_text) {
2140
- setStatus(textNormalizationStatus, startData.text_normalization_status_text);
2141
- }
2142
- updateNormalizedOutputs(startData);
2143
- renderPlaybackScript(startData.text_chunks, startData.normalized_text || textInput.value);
2144
-
2145
- const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
2146
- if (!AudioContextCtor) {
2147
- throw new Error("This browser does not support Web Audio streaming playback.");
2148
- }
2149
- currentInitialPlaybackDelaySeconds = resolveInitialPlaybackDelaySeconds();
2150
- currentAudioContext = new AudioContextCtor({ sampleRate: startData.sample_rate });
2151
- await currentAudioContext.resume();
2152
- nextPlaybackTime = currentAudioContext.currentTime + currentInitialPlaybackDelaySeconds;
2153
- currentRealtimePlaybackPaused = false;
2154
- resetRealtimePlaybackTracking();
2155
- clearRealtimePlaybackCompletionTimer();
2156
- updatePauseButtonState();
2157
- currentStreamAbortController = new AbortController();
2158
-
2159
- async function updateStreamStatus() {
2160
- if (!currentStreamId) {
2161
- return null;
2162
- }
2163
- const snapshot = await fetchJson(startData.status_url);
2164
- if (snapshot.run_status) {
2165
- setStatus(runStatus, snapshot.run_status, Boolean(snapshot.failed));
2166
- }
2167
- const metrics = [
2168
- `state=${snapshot.state}`,
2169
- `emitted=${Number(snapshot.emitted_audio_seconds || 0).toFixed(2)}s`,
2170
- `lead=${Number(snapshot.lead_seconds || 0).toFixed(2)}s`,
2171
- `playback_delay=${currentInitialPlaybackDelaySeconds.toFixed(2)}s`
2172
- ];
2173
- if (snapshot.first_audio_latency_seconds !== null && snapshot.first_audio_latency_seconds !== undefined) {
2174
- metrics.push(`first_audio=${Number(snapshot.first_audio_latency_seconds).toFixed(2)}s`);
2175
- }
2176
- streamMetrics.textContent = metrics.join(" | ");
2177
- if (!currentRealtimePlaybackPaused && snapshot.playback_chunk_index !== null && snapshot.playback_chunk_index !== undefined) {
2178
- setPlaybackHighlight(snapshot.playback_chunk_index);
2179
- }
2180
- return snapshot;
2181
- }
2182
-
2183
- currentStreamStatusTimer = window.setInterval(() => {
2184
- updateStreamStatus().catch(() => {});
2185
- }, 500);
2186
- await updateStreamStatus();
2187
-
2188
- const response = await fetch(startData.audio_url, {
2189
- signal: currentStreamAbortController.signal
2190
- });
2191
- if (!response.ok) {
2192
- const text = await response.text();
2193
- throw new Error(text || `HTTP ${response.status}`);
2194
- }
2195
- if (!response.body) {
2196
- throw new Error("ReadableStream is not available on this response.");
2197
- }
2198
-
2199
- const reader = response.body.getReader();
2200
- const channels = Number(startData.channels || 2);
2201
- const sampleRate = Number(startData.sample_rate || 48000);
2202
- const bytesPerFrame = channels * 2;
2203
- let remainder = new Uint8Array(0);
2204
-
2205
- while (true) {
2206
- const { done, value } = await reader.read();
2207
- if (done) {
2208
- break;
2209
- }
2210
- if (!value || value.length === 0) {
2211
- continue;
2212
- }
2213
- const merged = mergeUint8Arrays(remainder, value);
2214
- const alignedLength = Math.floor(merged.length / bytesPerFrame) * bytesPerFrame;
2215
- if (alignedLength <= 0) {
2216
- remainder = merged;
2217
- continue;
2218
- }
2219
- schedulePcmChunk(merged.subarray(0, alignedLength), sampleRate, channels);
2220
- remainder = merged.subarray(alignedLength);
2221
- }
2222
-
2223
- await updateStreamStatus();
2224
- if (currentStreamStatusTimer) {
2225
- window.clearInterval(currentStreamStatusTimer);
2226
- currentStreamStatusTimer = null;
2227
- }
2228
- let result = null;
2229
- for (let attempt = 0; attempt < 40; attempt += 1) {
2230
- result = await fetchJson(startData.result_url);
2231
- if (result.ready) {
2232
- break;
2233
- }
2234
- await new Promise((resolve) => window.setTimeout(resolve, 100));
2235
- }
2236
- if (!result || !result.ready) {
2237
- throw new Error("Streaming finished but the final result is not ready yet.");
2238
- }
2239
- if (result.audio_base64) {
2240
- if (currentAudioObjectUrl) {
2241
- URL.revokeObjectURL(currentAudioObjectUrl);
2242
- currentAudioObjectUrl = null;
2243
- }
2244
- const audioBlob = base64ToBlob(result.audio_base64, "audio/wav");
2245
- currentAudioObjectUrl = URL.createObjectURL(audioBlob);
2246
- audioOutput.src = currentAudioObjectUrl;
2247
- audioOutput.load();
2248
- rebuildBufferedPlaybackBoundaries();
2249
- }
2250
- resolvedPrompt.textContent = "Generated speech is ready.";
2251
- streamMetrics.textContent = result.stream_metrics || streamMetrics.textContent;
2252
- if (Array.isArray(result.text_chunks) && result.text_chunks.length > 0 && playbackChunks.length === 0) {
2253
- renderPlaybackScript(result.text_chunks, normalizedTextOutput.value || textInput.value);
2254
- }
2255
- currentRealtimePlaybackChunkRanges = normalizeRealtimeChunkRanges(result.audio_chunk_ranges);
2256
- updateRealtimePlaybackHighlightFromLocalClock();
2257
- setStatus(runStatus, result.run_status || "Stream complete.");
2258
- if (currentStreamId) {
2259
- fetch(`${APP_BASE}/api/generate-stream/${encodeURIComponent(currentStreamId)}/close`, {
2260
- method: "POST"
2261
- }).catch(() => {});
2262
- currentStreamId = null;
2263
- }
2264
- monitorRealtimePlaybackCompletion();
2265
- updatePauseButtonState();
2266
- }
2267
-
2268
- async function togglePausePlayback() {
2269
- if (hasRealtimePlayback()) {
2270
- if (currentRealtimePlaybackPaused) {
2271
- await currentAudioContext.resume();
2272
- currentRealtimePlaybackPaused = false;
2273
- updateRealtimePlaybackHighlightFromLocalClock();
2274
- } else {
2275
- await currentAudioContext.suspend();
2276
- currentRealtimePlaybackPaused = true;
2277
- }
2278
- updatePauseButtonState();
2279
- return;
2280
- }
2281
-
2282
- if (hasBufferedPlayback()) {
2283
- if (audioOutput.paused) {
2284
- await audioOutput.play().catch(() => {});
2285
- } else {
2286
- audioOutput.pause();
2287
- }
2288
- updatePauseButtonState();
2289
- }
2290
- }
2291
-
2292
- async function generate() {
2293
- generateBtn.disabled = true;
2294
- refreshBtn.disabled = true;
2295
- setStatus(runStatus, realtimeStreamToggle.checked ? "Starting realtime stream..." : "Running synthesis...");
2296
-
2297
- try {
2298
- const formData = buildFormData();
2299
- clearNormalizedOutputs();
2300
- renderPlaybackScript([], textInput.value);
2301
- if (realtimeStreamToggle.checked) {
2302
- await generateRealtime(formData);
2303
- } else {
2304
- await closeRealtimeStream();
2305
- await generateBuffered(formData);
2306
- }
2307
- } catch (error) {
2308
- if (realtimeStreamToggle.checked) {
2309
- await closeRealtimeStream();
2310
- }
2311
- setStatus(runStatus, String(error), true);
2312
- } finally {
2313
- generateBtn.disabled = false;
2314
- refreshBtn.disabled = false;
2315
- }
2316
- }
2317
-
2318
- generateBtn.addEventListener("click", generate);
2319
- demoSelect.addEventListener("change", async () => {
2320
- await closeRealtimeStream();
2321
- clearAudioOutput();
2322
- applySelectedDemo(true);
2323
- clearNormalizedOutputs();
2324
- resolvedPrompt.textContent = "";
2325
- streamMetrics.textContent = "";
2326
- setStatus(runStatus, "Idle.");
2327
- });
2328
- promptAudioUploadInput.addEventListener("change", () => {
2329
- applySelectedDemo(false);
2330
- clearNormalizedOutputs();
2331
- resolvedPrompt.textContent = "";
2332
- setStatus(runStatus, "Idle.");
2333
- });
2334
- choosePromptAudioBtn.addEventListener("click", () => {
2335
- promptAudioUploadInput.click();
2336
- });
2337
- clearPromptAudioBtn.addEventListener("click", () => {
2338
- promptAudioUploadInput.value = "";
2339
- applySelectedDemo(false);
2340
- clearNormalizedOutputs();
2341
- resolvedPrompt.textContent = "";
2342
- setStatus(runStatus, "Idle.");
2343
- });
2344
- pauseBtn.addEventListener("click", () => {
2345
- togglePausePlayback().catch((error) => {
2346
- setStatus(runStatus, String(error), true);
2347
- });
2348
- });
2349
- refreshBtn.addEventListener("click", refreshWarmupStatus);
2350
- textInput.addEventListener("input", previewPlaybackScriptFromInputs);
2351
- audioOutput.addEventListener("loadedmetadata", () => {
2352
- rebuildBufferedPlaybackBoundaries();
2353
- updateBufferedPlaybackHighlight();
2354
- });
2355
- audioOutput.addEventListener("timeupdate", updateBufferedPlaybackHighlight);
2356
- audioOutput.addEventListener("play", () => {
2357
- currentPlaybackMarkedComplete = false;
2358
- updatePauseButtonState();
2359
- updateBufferedPlaybackHighlight();
2360
- });
2361
- audioOutput.addEventListener("pause", updatePauseButtonState);
2362
- audioOutput.addEventListener("ended", () => {
2363
- updatePauseButtonState();
2364
- setPlaybackHighlight(null, { markAllPlayed: true });
2365
- });
2366
- window.addEventListener("beforeunload", () => {
2367
- clearPromptAudioPreviewUrl();
2368
- });
2369
- updatePauseButtonState();
2370
- applySelectedDemo(true);
2371
- refreshWarmupStatus();
2372
- window.setInterval(refreshWarmupStatus, 5000);
2373
- </script>
2374
- </body>
2375
- </html>
2376
- """
2377
  demos_payload = [
2378
  {
2379
  "id": demo_entry.demo_id,
@@ -2402,7 +698,7 @@ def _render_index_html(
2402
  def _build_app(
2403
  runtime: NanoTTSService,
2404
  warmup_manager: WarmupManager,
2405
- text_normalizer_manager: WeTextProcessingManager | None,
2406
  root_path: str | None,
2407
  ) -> FastAPI:
2408
  app = FastAPI(title="MOSS-TTS-Nano Demo", root_path=root_path or "")
 
154
 
155
 
156
  class WarmupManager:
157
+ def __init__(self, runtime: NanoTTSService, text_normalizer_manager: "SharedWeTextProcessingManager | None" = None) -> None:
158
  self.runtime = runtime
159
  self.text_normalizer_manager = text_normalizer_manager
160
  self._lock = threading.Lock()
 
666
  text_normalization_status: str,
667
  ) -> str:
668
  base_path = request.scope.get("root_path", "").rstrip("/")
669
+ import os
670
+ template_path = os.path.join(os.path.dirname(__file__), "ui", "templates", "index.html")
671
+ with open(template_path, "r", encoding="utf-8") as f:
672
+ template = f.read()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
673
  demos_payload = [
674
  {
675
  "id": demo_entry.demo_id,
 
698
  def _build_app(
699
  runtime: NanoTTSService,
700
  warmup_manager: WarmupManager,
701
+ text_normalizer_manager: "SharedWeTextProcessingManager | None",
702
  root_path: str | None,
703
  ) -> FastAPI:
704
  app = FastAPI(title="MOSS-TTS-Nano Demo", root_path=root_path or "")
extract_html.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+
4
+ def extract_html():
5
+ app_path = Path("app.py")
6
+ content = app_path.read_text(encoding="utf-8")
7
+
8
+ start_marker = ' template = """\n<!doctype html>'
9
+ end_marker = '\n</html>\n"""'
10
+
11
+ start_idx = content.find(start_marker)
12
+ if start_idx == -1:
13
+ print("Start marker not found.")
14
+ return
15
+
16
+ end_idx = content.find(end_marker, start_idx)
17
+ if end_idx == -1:
18
+ print("End marker not found.")
19
+ return
20
+
21
+ # Extract the HTML content
22
+ html_content = content[start_idx + len(' template = """\n') : end_idx + len('\n</html>\n')]
23
+
24
+ # Write to ui/templates/index.html
25
+ os.makedirs("ui/templates", exist_ok=True)
26
+ Path("ui/templates/index.html").write_text(html_content, encoding="utf-8")
27
+
28
+ # Replace in app.py
29
+ replacement = ''' import os
30
+ template_path = os.path.join(os.path.dirname(__file__), "ui", "templates", "index.html")
31
+ with open(template_path, "r", encoding="utf-8") as f:
32
+ template = f.read()'''
33
+
34
+ new_content = content[:start_idx] + replacement + content[end_idx + len('\n"""'):]
35
+ app_path.write_text(new_content, encoding="utf-8")
36
+ print("Successfully extracted HTML to ui/templates/index.html and updated app.py!")
37
+
38
+ if __name__ == "__main__":
39
+ extract_html()
split_app.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ from pathlib import Path
4
+
5
+ def main():
6
+ with open("app.py", "r", encoding="utf-8") as f:
7
+ content = f.read()
8
+
9
+ # Create directories
10
+ os.makedirs("core", exist_ok=True)
11
+ os.makedirs("api", exist_ok=True)
12
+ os.makedirs("ui/templates", exist_ok=True)
13
+
14
+ # 1. Extract HTML Template
15
+ # def _render_index_html( ... ) -> str: ... template = """<!doctype html>...""" return template
16
+ html_match = re.search(r'template = """(.*?)"""\s*replacements = {', content, re.DOTALL)
17
+ if html_match:
18
+ html_content = html_match.group(1).strip()
19
+ with open("ui/templates/index.html", "w", encoding="utf-8") as f:
20
+ f.write(html_content)
21
+ print("Extracted ui/templates/index.html")
22
+
23
+ # Replace the massive string in app.py with a file read (temporarily)
24
+ # to ensure we don't lose logic. Wait, it's better to create ui/render.py
25
+ else:
26
+ print("HTML template not found.")
27
+
28
+ if __name__ == "__main__":
29
+ main()
ui/templates/index.html ADDED
@@ -0,0 +1,1706 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>MOSS-TTS-Nano Demo</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light;
10
+ --bg: #f3f5f9;
11
+ --bg-soft: #f8fafc;
12
+ --panel: rgba(255, 255, 255, 0.75);
13
+ --text: #1e293b;
14
+ --muted: #64748b;
15
+ --line: rgba(226, 232, 240, 0.8);
16
+ --line-strong: #cbd5e1;
17
+ --chip: #e0e7ff;
18
+ --chip-text: #4338ca;
19
+ --accent: #6366f1;
20
+ --accent-strong: #4f46e5;
21
+ --accent-soft: rgba(99, 102, 241, 0.12);
22
+ --danger: #e11d48;
23
+ --shadow: 0 10px 25px rgba(0, 0, 0, 0.05);
24
+ }
25
+ * {
26
+ box-sizing: border-box;
27
+ }
28
+ body {
29
+ margin: 0;
30
+ min-height: 100vh;
31
+ background:
32
+ radial-gradient(1100px 380px at 12% -10%, #ffffff 0%, transparent 70%),
33
+ linear-gradient(180deg, var(--bg-soft) 0%, var(--bg) 54%);
34
+ color: var(--text);
35
+ font-family: "Plus Jakarta Sans", "Noto Sans SC", "Segoe UI", sans-serif;
36
+ }
37
+ .page {
38
+ max-width: 1460px;
39
+ margin: 0 auto;
40
+ padding: 24px 26px 30px;
41
+ }
42
+ .hero {
43
+ margin-bottom: 12px;
44
+ }
45
+ .hero h1 {
46
+ margin: 0 0 10px;
47
+ font-size: 38px;
48
+ letter-spacing: -0.03em;
49
+ }
50
+ .hero .lead {
51
+ margin: 0 0 8px;
52
+ color: var(--muted);
53
+ font-size: 19px;
54
+ line-height: 1.55;
55
+ max-width: 980px;
56
+ }
57
+ .hero-points {
58
+ margin: 0 0 8px 20px;
59
+ padding: 0;
60
+ color: #33415f;
61
+ line-height: 1.6;
62
+ }
63
+ .hero-points strong {
64
+ color: #121826;
65
+ }
66
+ .build-note {
67
+ margin: 0;
68
+ color: #4f5d7c;
69
+ }
70
+ .top-tabs {
71
+ display: flex;
72
+ align-items: center;
73
+ gap: 22px;
74
+ margin-top: 14px;
75
+ border-bottom: 1px solid var(--line);
76
+ }
77
+ .top-tab {
78
+ border: 0;
79
+ background: transparent;
80
+ color: #4e5f89;
81
+ font-size: 15px;
82
+ font-weight: 500;
83
+ padding: 10px 0;
84
+ position: relative;
85
+ cursor: default;
86
+ }
87
+ .top-tab.active {
88
+ color: var(--accent-strong);
89
+ font-weight: 700;
90
+ }
91
+ .top-tab.active::after {
92
+ content: "";
93
+ position: absolute;
94
+ left: 0;
95
+ right: 0;
96
+ bottom: -1px;
97
+ height: 2px;
98
+ background: var(--accent-strong);
99
+ }
100
+ .top-tab:disabled {
101
+ opacity: 1;
102
+ }
103
+ .grid {
104
+ display: grid;
105
+ grid-template-columns: minmax(0, 1.08fr) minmax(320px, 0.92fr);
106
+ gap: 14px;
107
+ margin-top: 14px;
108
+ }
109
+ .panel {
110
+ background: var(--panel);
111
+ backdrop-filter: blur(24px);
112
+ -webkit-backdrop-filter: blur(24px);
113
+ border: 1px solid rgba(255, 255, 255, 0.6);
114
+ border-radius: 16px;
115
+ padding: 20px;
116
+ box-shadow: var(--shadow), inset 0 1px 0 rgba(255, 255, 255, 0.8);
117
+ }
118
+ .field {
119
+ margin-bottom: 11px;
120
+ }
121
+ .field > label[for],
122
+ .field > .field-tag {
123
+ display: inline-flex;
124
+ align-items: center;
125
+ gap: 6px;
126
+ margin-bottom: 7px;
127
+ font-size: 14px;
128
+ line-height: 1;
129
+ font-weight: 700;
130
+ color: var(--chip-text);
131
+ background: var(--chip);
132
+ border-radius: 6px;
133
+ padding: 5px 8px;
134
+ }
135
+ .field > label[for]::before,
136
+ .field > .field-tag::before {
137
+ content: "♫";
138
+ font-size: 11px;
139
+ opacity: 0.75;
140
+ }
141
+ .field > label:not([for]):not(.field-tag) {
142
+ display: inline-flex;
143
+ align-items: center;
144
+ gap: 8px;
145
+ margin-top: 2px;
146
+ color: #273554;
147
+ font-size: 14px;
148
+ font-weight: 500;
149
+ }
150
+ .field input,
151
+ .field textarea,
152
+ .field select {
153
+ width: 100%;
154
+ border: 1px solid var(--line);
155
+ border-radius: 8px;
156
+ padding: 10px 12px;
157
+ font-size: 14px;
158
+ color: var(--text);
159
+ background: #fff;
160
+ transition: border-color 140ms ease, box-shadow 140ms ease;
161
+ }
162
+ .field input:focus,
163
+ .field textarea:focus,
164
+ .field select:focus {
165
+ outline: 0;
166
+ border-color: #aeb9f7;
167
+ box-shadow: 0 0 0 3px rgba(106, 110, 246, 0.15);
168
+ }
169
+ .field textarea {
170
+ min-height: 108px;
171
+ resize: vertical;
172
+ }
173
+ #normalized-text-output {
174
+ min-height: 114px;
175
+ background: #fbfcff;
176
+ }
177
+ input[type="file"] {
178
+ border-style: dashed;
179
+ border-color: var(--line-strong);
180
+ padding: 54px 12px;
181
+ background: linear-gradient(180deg, #ffffff 0%, #f5f8ff 100%);
182
+ color: #4d5d83;
183
+ }
184
+ input[type="file"]::file-selector-button {
185
+ border: 0;
186
+ border-radius: 999px;
187
+ padding: 8px 12px;
188
+ margin-right: 10px;
189
+ background: #edf1ff;
190
+ color: var(--chip-text);
191
+ font-weight: 700;
192
+ cursor: pointer;
193
+ }
194
+ .prompt-audio-box {
195
+ border: 1px solid var(--line);
196
+ border-radius: 8px;
197
+ background: linear-gradient(180deg, #ffffff 0%, #f7f9ff 100%);
198
+ padding: 10px;
199
+ }
200
+ #prompt-audio-preview {
201
+ margin-top: 0;
202
+ }
203
+ #prompt-audio-upload[hidden],
204
+ #prompt-audio-preview[hidden] {
205
+ display: none;
206
+ }
207
+ .prompt-audio-actions {
208
+ display: flex;
209
+ gap: 8px;
210
+ margin-top: 8px;
211
+ flex-wrap: wrap;
212
+ }
213
+ .prompt-audio-actions button {
214
+ min-height: 34px;
215
+ font-size: 13px;
216
+ padding: 8px 12px;
217
+ }
218
+ .row {
219
+ display: grid;
220
+ grid-template-columns: 1fr 1fr;
221
+ gap: 10px;
222
+ }
223
+ details {
224
+ border: 1px solid var(--line);
225
+ border-radius: 10px;
226
+ padding: 10px 12px;
227
+ margin: 12px 0;
228
+ background: #f9fafe;
229
+ }
230
+ summary {
231
+ cursor: pointer;
232
+ font-weight: 600;
233
+ color: #2f3f65;
234
+ }
235
+ .buttons {
236
+ display: grid;
237
+ grid-template-columns: minmax(0, 1fr) auto auto;
238
+ gap: 10px;
239
+ margin-top: 12px;
240
+ }
241
+ button:not(.top-tab) {
242
+ border: 0;
243
+ border-radius: 10px;
244
+ padding: 12px 18px;
245
+ font-size: 15px;
246
+ font-weight: 700;
247
+ cursor: pointer;
248
+ background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
249
+ color: #fff;
250
+ transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
251
+ box-shadow: 0 4px 6px rgba(79, 70, 229, 0.15);
252
+ }
253
+ button:not(.top-tab):hover {
254
+ transform: translateY(-2px);
255
+ box-shadow: 0 8px 15px rgba(79, 70, 229, 0.3);
256
+ }
257
+ #generate-btn {
258
+ width: 100%;
259
+ min-height: 42px;
260
+ }
261
+ button.secondary {
262
+ background: #edf1fb;
263
+ color: #44527a;
264
+ border: 1px solid var(--line-strong);
265
+ }
266
+ button:not(.top-tab):disabled {
267
+ opacity: 0.6;
268
+ cursor: wait;
269
+ transform: none;
270
+ box-shadow: none;
271
+ }
272
+ .status {
273
+ white-space: pre-wrap;
274
+ line-height: 1.5;
275
+ font-size: 14px;
276
+ color: var(--muted);
277
+ min-height: 52px;
278
+ border: 1px solid var(--line);
279
+ border-radius: 8px;
280
+ background: #fcfdff;
281
+ padding: 10px 12px;
282
+ }
283
+ .status.error {
284
+ color: var(--danger);
285
+ border-color: rgba(186, 31, 70, 0.28);
286
+ background: rgba(186, 31, 70, 0.06);
287
+ }
288
+ .meta {
289
+ font-size: 13px;
290
+ color: var(--muted);
291
+ margin-top: 7px;
292
+ line-height: 1.5;
293
+ }
294
+ .playback-script {
295
+ min-height: 108px;
296
+ display: flex;
297
+ flex-wrap: wrap;
298
+ align-content: flex-start;
299
+ gap: 10px;
300
+ padding: 12px;
301
+ border: 1px solid var(--line);
302
+ border-radius: 8px;
303
+ background: linear-gradient(180deg, #ffffff 0%, #f6f8fe 100%);
304
+ overflow: auto;
305
+ }
306
+ .playback-script.empty {
307
+ display: block;
308
+ color: var(--muted);
309
+ }
310
+ .playback-segment {
311
+ display: inline-flex;
312
+ align-items: center;
313
+ padding: 8px 11px;
314
+ border-radius: 10px;
315
+ border: 1px solid var(--line);
316
+ background: #ffffff;
317
+ color: #4a5c82;
318
+ line-height: 1.6;
319
+ transition: background-color 160ms ease, color 160ms ease, border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
320
+ }
321
+ .playback-segment.played {
322
+ border-color: rgba(106, 110, 246, 0.24);
323
+ background: var(--accent-soft);
324
+ color: #424db6;
325
+ }
326
+ .playback-segment.active {
327
+ border-color: var(--accent-strong);
328
+ background: var(--accent);
329
+ color: #ffffff;
330
+ box-shadow: 0 8px 18px rgba(106, 110, 246, 0.32);
331
+ transform: translateY(-1px);
332
+ }
333
+ audio {
334
+ width: 100%;
335
+ margin-top: 10px;
336
+ border-radius: 8px;
337
+ background: #f8f9fe;
338
+ }
339
+ code {
340
+ padding: 2px 6px;
341
+ border-radius: 4px;
342
+ background: #eef1fb;
343
+ color: #33426a;
344
+ }
345
+ a {
346
+ color: #4a5be0;
347
+ text-decoration: none;
348
+ }
349
+ a:hover {
350
+ text-decoration: underline;
351
+ }
352
+ @media (max-width: 1120px) {
353
+ .grid {
354
+ grid-template-columns: 1fr;
355
+ }
356
+ .buttons {
357
+ grid-template-columns: 1fr;
358
+ }
359
+ .hero h1 {
360
+ font-size: 32px;
361
+ }
362
+ }
363
+ @media (max-width: 860px) {
364
+ .row {
365
+ grid-template-columns: 1fr;
366
+ }
367
+ .page {
368
+ padding: 16px 14px 20px;
369
+ }
370
+ .hero .lead {
371
+ font-size: 16px;
372
+ }
373
+ .hero h1 {
374
+ font-size: 28px;
375
+ }
376
+ .top-tabs {
377
+ gap: 14px;
378
+ }
379
+ }
380
+ </style>
381
+ </head>
382
+ <body>
383
+ <div class="page">
384
+ <div class="hero">
385
+ <h1>MOSS-TTS-Nano Demo</h1>
386
+ <p class="lead">State-of-the-art text-to-speech demo for multilingual voice cloning.</p>
387
+ <ul class="hero-points">
388
+ <li><strong>Voice Clone</strong> - Clone any voice from a reference audio.</li>
389
+ <li><strong>Voice Presets</strong> - Choose built-in demos from <code>assets/demo.jsonl</code>.</li>
390
+ </ul>
391
+ <p class="build-note">Built with <a href="https://github.com/OpenMOSS/MOSS-TTS-Nano" target="_blank" rel="noopener noreferrer">MOSS-TTS-Nano</a>.</p>
392
+ <div class="top-tabs" role="tablist" aria-label="Demo mode">
393
+ <button class="top-tab active" type="button" id="tab-voice-clone" aria-selected="true">Voice Clone</button>
394
+ <button class="top-tab" type="button" id="tab-dialogue">Multi-Speaker</button>
395
+ <a href="docs" target="_blank" class="top-tab" style="text-decoration: none; margin-left: auto; display: flex; align-items: center; gap: 4px;">
396
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
397
+ API Docs
398
+ </a>
399
+ </div>
400
+ </div>
401
+
402
+ <div class="grid" id="view-voice-clone">
403
+ <div class="panel input-panel">
404
+ <div class="field">
405
+ <label for="demo">Demo</label>
406
+ <select id="demo"></select>
407
+ </div>
408
+
409
+ <div class="field">
410
+ <label for="prompt-audio-upload">Prompt Speech</label>
411
+ <div class="prompt-audio-box">
412
+ <input id="prompt-audio-upload" type="file" accept="audio/*,.wav,.mp3,.flac,.m4a,.ogg,.opus,.aac">
413
+ <audio id="prompt-audio-preview" controls hidden></audio>
414
+ <div id="prompt-audio-source" class="meta">Using the selected demo prompt speech.</div>
415
+ <div class="prompt-audio-actions">
416
+ <button id="choose-prompt-audio-btn" class="secondary" type="button" hidden>选择文件</button>
417
+ <button id="clear-prompt-audio-btn" class="secondary" type="button" hidden>使用 Demo 音频</button>
418
+ </div>
419
+ </div>
420
+ </div>
421
+
422
+ <div class="field">
423
+ <label for="text">Text</label>
424
+ <textarea id="text" placeholder="Enter the text you want to synthesize..."></textarea>
425
+ </div>
426
+
427
+ <details>
428
+ <summary>Generation Options</summary>
429
+ <div class="row" style="margin-top: 12px;">
430
+ <div class="field">
431
+ <label for="max-new-frames">Max New Frames</label>
432
+ <input id="max-new-frames" type="number" min="64" max="1024" step="1" value="375">
433
+ </div>
434
+ <div class="field">
435
+ <label for="voice-clone-max-text-tokens">Voice Clone Max Text Tokens</label>
436
+ <input id="voice-clone-max-text-tokens" type="number" min="25" max="200" step="1" value="75">
437
+ </div>
438
+ </div>
439
+ <div class="row">
440
+ <div class="field">
441
+ <label for="tts-max-batch-size">Max TTS Batch Size (0=auto)</label>
442
+ <input id="tts-max-batch-size" type="number" min="0" step="1" value="1">
443
+ </div>
444
+ <div class="field">
445
+ <label for="codec-max-batch-size">Max Codec Batch Size (0=auto)</label>
446
+ <input id="codec-max-batch-size" type="number" min="0" step="1" value="0">
447
+ </div>
448
+ </div>
449
+ <div class="meta">
450
+ 0 keeps the current default behavior. Set Max TTS Batch Size to 1 to force split chunks to run one by one.
451
+ Buffered generation keeps chunk order and decodes codec sub-batches no larger than the current TTS batch.
452
+ Realtime Streaming Decode keeps output order and uses the smallest active chunk-group width among auto batching, Max TTS Batch Size, and Max Codec Batch Size.
453
+ </div>
454
+ <div class="field">
455
+ <label for="cpu-thread-count">CPU Threads</label>
456
+ <input id="cpu-thread-count" type="number" min="1" step="1" value="4">
457
+ </div>
458
+ <div class="meta">
459
+ This app is CPU-only. CPU Threads maps to torch.set_num_threads for that request.
460
+ </div>
461
+ <div class="row">
462
+ <div class="field">
463
+ <label for="attn-implementation">Attention Backend</label>
464
+ <select id="attn-implementation">
465
+ <option value="model_default">model_default</option>
466
+ <option value="sdpa">sdpa</option>
467
+ <option value="eager">eager</option>
468
+ </select>
469
+ </div>
470
+ <div class="field">
471
+ <label for="seed">Seed</label>
472
+ <input id="seed" type="number" step="1" value="0">
473
+ </div>
474
+ </div>
475
+ <div class="row">
476
+ <div class="field">
477
+ <label for="text-temperature">Text Temperature</label>
478
+ <input id="text-temperature" type="number" min="0.1" max="2.0" step="0.05" value="1.0">
479
+ </div>
480
+ <div class="field">
481
+ <label for="text-top-p">Text Top P</label>
482
+ <input id="text-top-p" type="number" min="0.1" max="1.0" step="0.05" value="1.0">
483
+ </div>
484
+ </div>
485
+ <div class="row">
486
+ <div class="field">
487
+ <label for="text-top-k">Text Top K</label>
488
+ <input id="text-top-k" type="number" min="1" max="100" step="1" value="50">
489
+ </div>
490
+ <div class="field">
491
+ <label for="audio-temperature">Audio Temperature</label>
492
+ <input id="audio-temperature" type="number" min="0.1" max="2.0" step="0.05" value="0.8">
493
+ </div>
494
+ </div>
495
+ <div class="row">
496
+ <div class="field">
497
+ <label for="audio-top-p">Audio Top P</label>
498
+ <input id="audio-top-p" type="number" min="0.1" max="1.0" step="0.05" value="0.95">
499
+ </div>
500
+ <div class="field">
501
+ <label for="audio-top-k">Audio Top K</label>
502
+ <input id="audio-top-k" type="number" min="1" max="100" step="1" value="25">
503
+ </div>
504
+ </div>
505
+ <div class="row">
506
+ <div class="field">
507
+ <label for="audio-repetition-penalty">Audio Repetition Penalty</label>
508
+ <input id="audio-repetition-penalty" type="number" min="1.0" max="2.0" step="0.05" value="1.2">
509
+ </div>
510
+ <div class="field"></div>
511
+ </div>
512
+ <div class="field">
513
+ <label><input id="do-sample" type="checkbox" checked> Do Sample</label>
514
+ </div>
515
+ <div class="field">
516
+ <label><input id="enable-text-normalization" type="checkbox" checked> Enable WeTextProcessing</label>
517
+ </div>
518
+ <div class="field">
519
+ <label><input id="enable-robust-text-normalization" type="checkbox" checked> Enable normalize_tts_text</label>
520
+ </div>
521
+ <div class="meta">
522
+ WeTextProcessing and normalize_tts_text can now be toggled independently for each request.
523
+ WeTextProcessing is preloaded during startup so enabling it does not add first-request graph-build latency.
524
+ </div>
525
+ <div class="row">
526
+ <div class="field">
527
+ <label><input id="realtime-stream" type="checkbox" checked> Realtime Streaming Decode</label>
528
+ </div>
529
+ <div class="field">
530
+ <label for="initial-playback-delay-seconds">Initial Playback Delay (s)</label>
531
+ <input id="initial-playback-delay-seconds" type="number" min="0.00" step="0.01" value="0.08">
532
+ </div>
533
+ </div>
534
+ </details>
535
+
536
+ <div class="buttons">
537
+ <button id="generate-btn" type="button">Generate</button>
538
+ <button id="pause-btn" class="secondary" type="button" disabled>Pause Playback</button>
539
+ <button id="refresh-btn" class="secondary" type="button" style="display:none;">Refresh Warmup Status</button>
540
+ </div>
541
+ </div>
542
+
543
+ <div class="panel output-panel">
544
+ <div class="field" style="display:none;">
545
+ <label class="field-tag">Warmup Status</label>
546
+ <div id="warmup-status" class="status">__WARMUP_STATUS__</div>
547
+ </div>
548
+ <div class="field" style="display:none;">
549
+ <label class="field-tag">Text Normalization Status</label>
550
+ <div id="text-normalization-status" class="status">__TEXT_NORMALIZATION_STATUS__</div>
551
+ </div>
552
+ <div class="field">
553
+ <label class="field-tag">Run Status</label>
554
+ <div id="run-status" class="status">Idle.</div>
555
+ </div>
556
+ <div id="stream-metrics" class="meta"></div>
557
+ <div class="field" style="display:none;">
558
+ <label class="field-tag">Normalized Text</label>
559
+ <textarea id="normalized-text-output" readonly style="min-height: 120px;"></textarea>
560
+ </div>
561
+ <div class="field">
562
+ <label class="field-tag">Playback Script</label>
563
+ <div id="playback-script" class="playback-script empty">The current sentence will be highlighted here during playback.</div>
564
+ </div>
565
+ <div class="field">
566
+ <label class="field-tag">Generated Speech</label>
567
+ <div id="resolved-prompt" class="meta"></div>
568
+ </div>
569
+ <audio id="audio-output" controls></audio>
570
+ <div class="meta">Checkpoint: __CHECKPOINT__</div>
571
+ <div class="meta">Audio Tokenizer: __AUDIO_TOKENIZER__</div>
572
+ </div>
573
+ </div>
574
+
575
+ <!-- Dialogue View -->
576
+ <div class="grid" id="view-dialogue" style="display: none; grid-template-columns: minmax(0, 1.08fr) minmax(320px, 0.92fr);">
577
+ <div class="panel">
578
+ <h3 style="margin-top:0; color:#1e293b; font-size:18px;">Dialogue Editor</h3>
579
+ <div id="dialogue-blocks-container" style="display:flex; flex-direction:column; gap: 16px;"></div>
580
+ <button id="add-dialogue-block-btn" class="secondary" style="margin-top: 16px; width: 100%; border-style: dashed;">+ Add Line</button>
581
+
582
+ <div class="buttons" style="margin-top: 24px;">
583
+ <button id="generate-dialogue-btn" type="button" style="width: 100%;">Generate Dialogue</button>
584
+ </div>
585
+ </div>
586
+ <div class="panel">
587
+ <h3 style="margin-top:0; color:#1e293b; font-size:18px;">Speaker Settings</h3>
588
+ <div id="speakers-container" style="display:flex; flex-direction:column; gap: 12px;"></div>
589
+ <button id="add-speaker-btn" class="secondary" style="margin-top: 12px; width: 100%; font-size: 13px; border-style: dashed;">+ Add Speaker</button>
590
+
591
+ <hr style="border: 0; border-top: 1px solid var(--line); margin: 24px 0;">
592
+ <h3 style="margin-top:0; color:#1e293b; font-size:18px;">Output</h3>
593
+ <div id="dialogue-run-status" class="status" style="margin-bottom: 12px;">Idle.</div>
594
+ <audio id="dialogue-audio-output" controls style="width: 100%; display: none;"></audio>
595
+ </div>
596
+ </div>
597
+ </div>
598
+
599
+ <script>
600
+ const APP_BASE = __APP_BASE__;
601
+ const DEMOS = __DEMOS__;
602
+ const DEFAULT_DEMO_ID = __DEFAULT_DEMO_ID__;
603
+ const DEFAULT_ATTN_IMPLEMENTATION = __DEFAULT_ATTN_IMPLEMENTATION__;
604
+ const DEFAULT_CPU_THREADS = __DEFAULT_CPU_THREADS__;
605
+
606
+ // ==========================================
607
+ // Multi-Speaker Dialogue Logic
608
+ // ==========================================
609
+ const tabVoiceClone = document.getElementById("tab-voice-clone");
610
+ const tabDialogue = document.getElementById("tab-dialogue");
611
+ const viewVoiceClone = document.getElementById("view-voice-clone");
612
+ const viewDialogue = document.getElementById("view-dialogue");
613
+
614
+ if (tabVoiceClone && tabDialogue) {
615
+ tabVoiceClone.addEventListener("click", () => {
616
+ tabVoiceClone.classList.add("active");
617
+ tabDialogue.classList.remove("active");
618
+ viewVoiceClone.style.display = "grid";
619
+ viewDialogue.style.display = "none";
620
+ });
621
+ tabDialogue.addEventListener("click", () => {
622
+ tabDialogue.classList.add("active");
623
+ tabVoiceClone.classList.remove("active");
624
+ viewDialogue.style.display = "grid";
625
+ viewVoiceClone.style.display = "none";
626
+ renderDialogueUI();
627
+ });
628
+ }
629
+
630
+ let dialogueSpeakers = [
631
+ { id: 1, name: "Speaker 1", demoId: DEMOS.length > 0 ? DEMOS[0].id : null },
632
+ { id: 2, name: "Speaker 2", demoId: DEMOS.length > 1 ? DEMOS[1].id : (DEMOS.length > 0 ? DEMOS[0].id : null) }
633
+ ];
634
+ let dialogueBlocks = [
635
+ { id: 1, speakerId: 1, text: "[shouting] Halt, traveler! The northern pass is sealed." },
636
+ { id: 2, speakerId: 2, text: "[determination] I carry a message. Step aside." }
637
+ ];
638
+ let speakerIdCounter = 3;
639
+ let blockIdCounter = 3;
640
+
641
+ function renderDialogueUI() {
642
+ const speakersContainer = document.getElementById("speakers-container");
643
+ const blocksContainer = document.getElementById("dialogue-blocks-container");
644
+
645
+ // Render Speakers
646
+ speakersContainer.innerHTML = "";
647
+ dialogueSpeakers.forEach((spk, index) => {
648
+ const div = document.createElement("div");
649
+ div.style.cssText = "padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: #f8fafc;";
650
+
651
+ let optionsHtml = DEMOS.map(d => `<option value="${d.id}" ${spk.demoId === d.id ? 'selected' : ''}>${d.name}</option>`).join('');
652
+
653
+ div.innerHTML = `
654
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
655
+ <input type="text" value="${spk.name}" onchange="updateSpeakerName(${spk.id}, this.value)" style="font-weight: 700; border: none; background: transparent; padding: 0; margin: 0; color: var(--accent-strong); font-size: 14px; outline: none; width: 120px;">
656
+ ${dialogueSpeakers.length > 1 ? `<button onclick="removeSpeaker(${spk.id})" style="background:transparent; border:none; color:var(--danger); padding:0; box-shadow:none; font-size: 12px;">Remove</button>` : ''}
657
+ </div>
658
+ <div class="field" style="margin: 0;">
659
+ <select onchange="updateSpeakerVoice(${spk.id}, this.value)" style="padding: 6px 8px; font-size: 13px;">
660
+ ${optionsHtml}
661
+ </select>
662
+ </div>
663
+ `;
664
+ speakersContainer.appendChild(div);
665
+ });
666
+
667
+ // Render Blocks
668
+ blocksContainer.innerHTML = "";
669
+ dialogueBlocks.forEach((blk, index) => {
670
+ const div = document.createElement("div");
671
+ div.style.cssText = "border: 1px solid var(--line); border-radius: 12px; overflow: hidden; background: #fff;";
672
+
673
+ let spkOptions = dialogueSpeakers.map(s => `<option value="${s.id}" ${blk.speakerId === s.id ? 'selected' : ''}>👤 ${s.name}</option>`).join('');
674
+
675
+ div.innerHTML = `
676
+ <div style="background: #f1f5f9; padding: 8px 12px; border-bottom: 1px solid var(--line); display: flex; justify-content: space-between; align-items: center;">
677
+ <select onchange="updateBlockSpeaker(${blk.id}, this.value)" style="border:none; background:transparent; font-weight: 700; font-size: 13px; color: #334155; outline: none; padding: 0;">
678
+ ${spkOptions}
679
+ </select>
680
+ <button onclick="removeBlock(${blk.id})" style="background:transparent; border:none; color:var(--muted); padding:0; box-shadow:none; font-size: 12px; cursor: pointer;">✕</button>
681
+ </div>
682
+ <div style="padding: 12px;">
683
+ <textarea onchange="updateBlockText(${blk.id}, this.value)" placeholder="Enter dialogue line here..." style="width: 100%; border: none; resize: vertical; min-height: 60px; outline: none; font-size: 14px; padding: 0; box-shadow: none;">${blk.text}</textarea>
684
+ </div>
685
+ `;
686
+ blocksContainer.appendChild(div);
687
+ });
688
+ }
689
+
690
+ window.updateSpeakerName = (id, name) => { const s = dialogueSpeakers.find(x => x.id === id); if(s) s.name = name; renderDialogueUI(); };
691
+ window.updateSpeakerVoice = (id, voice) => { const s = dialogueSpeakers.find(x => x.id === id); if(s) s.demoId = voice; };
692
+ window.removeSpeaker = (id) => { dialogueSpeakers = dialogueSpeakers.filter(x => x.id !== id); renderDialogueUI(); };
693
+ window.updateBlockSpeaker = (id, spkId) => { const b = dialogueBlocks.find(x => x.id === id); if(b) b.speakerId = parseInt(spkId); };
694
+ window.updateBlockText = (id, text) => { const b = dialogueBlocks.find(x => x.id === id); if(b) b.text = text; };
695
+ window.removeBlock = (id) => { dialogueBlocks = dialogueBlocks.filter(x => x.id !== id); renderDialogueUI(); };
696
+
697
+ const addSpeakerBtn = document.getElementById("add-speaker-btn");
698
+ if(addSpeakerBtn) {
699
+ addSpeakerBtn.addEventListener("click", () => {
700
+ dialogueSpeakers.push({ id: speakerIdCounter++, name: "Speaker " + speakerIdCounter, demoId: DEMOS[0]?.id });
701
+ renderDialogueUI();
702
+ });
703
+ }
704
+
705
+ const addBlockBtn = document.getElementById("add-dialogue-block-btn");
706
+ if(addBlockBtn) {
707
+ addBlockBtn.addEventListener("click", () => {
708
+ dialogueBlocks.push({ id: blockIdCounter++, speakerId: dialogueSpeakers[0]?.id || 1, text: "" });
709
+ renderDialogueUI();
710
+ });
711
+ }
712
+
713
+ const genBtn = document.getElementById("generate-dialogue-btn");
714
+ if(genBtn) {
715
+ genBtn.addEventListener("click", async () => {
716
+ if(dialogueBlocks.length === 0) return;
717
+ genBtn.disabled = true;
718
+ const statusDiv = document.getElementById("dialogue-run-status");
719
+ const audioOut = document.getElementById("dialogue-audio-output");
720
+ audioOut.style.display = "none";
721
+ audioOut.removeAttribute("src");
722
+
723
+ statusDiv.textContent = "Generating...";
724
+ statusDiv.classList.remove("error");
725
+
726
+ let pcmChunks = [];
727
+ let currentSampleRate = null;
728
+ let currentChannels = null;
729
+
730
+ try {
731
+ for (let i = 0; i < dialogueBlocks.length; i++) {
732
+ const blk = dialogueBlocks[i];
733
+ if(!blk.text.trim()) continue;
734
+
735
+ const spk = dialogueSpeakers.find(s => s.id === blk.speakerId);
736
+ if(!spk) continue;
737
+
738
+ statusDiv.textContent = `Generating Block ${i+1}/${dialogueBlocks.length} (${spk.name})...`;
739
+
740
+ const fd = new FormData();
741
+ fd.append("text", blk.text);
742
+ fd.append("demo_id", spk.demoId);
743
+ // Append default params
744
+ fd.append("max_new_frames", "1024");
745
+ fd.append("voice_clone_max_text_tokens", "75");
746
+
747
+ const res = await fetch(`${APP_BASE}/api/generate`, { method: "POST", body: fd });
748
+ if(!res.ok) throw new Error(await res.text());
749
+ const data = await res.json();
750
+
751
+ if(!currentSampleRate) currentSampleRate = data.sample_rate;
752
+
753
+ // Decode base64 wav
754
+ const binary = atob(data.audio_base64);
755
+ const bytes = new Uint8Array(binary.length);
756
+ for(let j=0; j<binary.length; j++) bytes[j] = binary.charCodeAt(j);
757
+
758
+ // Slice out WAV header (assume standard 44 bytes)
759
+ pcmChunks.push(bytes.slice(44));
760
+
761
+ // If we need the header for the final audio
762
+ if(i === 0) {
763
+ window._wavHeaderTpl = bytes.slice(0, 44);
764
+ }
765
+ }
766
+
767
+ if(pcmChunks.length > 0) {
768
+ statusDiv.textContent = `Stitching audio...`;
769
+ let totalPcmLen = pcmChunks.reduce((acc, val) => acc + val.length, 0);
770
+ let finalWav = new Uint8Array(44 + totalPcmLen);
771
+ finalWav.set(window._wavHeaderTpl, 0);
772
+
773
+ // Update lengths in header
774
+ const dataView = new DataView(finalWav.buffer);
775
+ dataView.setUint32(40, totalPcmLen, true);
776
+ dataView.setUint32(4, 36 + totalPcmLen, true);
777
+
778
+ let offset = 44;
779
+ for(let chunk of pcmChunks) {
780
+ finalWav.set(chunk, offset);
781
+ offset += chunk.length;
782
+ }
783
+
784
+ const blob = new Blob([finalWav], { type: "audio/wav" });
785
+ audioOut.src = URL.createObjectURL(blob);
786
+ audioOut.style.display = "block";
787
+ statusDiv.textContent = "Done!";
788
+ } else {
789
+ statusDiv.textContent = "No valid text to generate.";
790
+ }
791
+
792
+ } catch(e) {
793
+ statusDiv.textContent = `Error: ${e.message}`;
794
+ statusDiv.classList.add("error");
795
+ } finally {
796
+ genBtn.disabled = false;
797
+ }
798
+ });
799
+ }
800
+
801
+ // ==========================================
802
+
803
+
804
+ const demoSelect = document.getElementById("demo");
805
+ const promptAudioUploadInput = document.getElementById("prompt-audio-upload");
806
+ const promptAudioPreview = document.getElementById("prompt-audio-preview");
807
+ const promptAudioSource = document.getElementById("prompt-audio-source");
808
+ const choosePromptAudioBtn = document.getElementById("choose-prompt-audio-btn");
809
+ const clearPromptAudioBtn = document.getElementById("clear-prompt-audio-btn");
810
+ const warmupStatus = document.getElementById("warmup-status");
811
+ const textNormalizationStatus = document.getElementById("text-normalization-status");
812
+ const runStatus = document.getElementById("run-status");
813
+ const streamMetrics = document.getElementById("stream-metrics");
814
+ const textInput = document.getElementById("text");
815
+ const normalizedTextOutput = document.getElementById("normalized-text-output");
816
+ const playbackScript = document.getElementById("playback-script");
817
+ const resolvedPrompt = document.getElementById("resolved-prompt");
818
+ const audioOutput = document.getElementById("audio-output");
819
+ const generateBtn = document.getElementById("generate-btn");
820
+ const pauseBtn = document.getElementById("pause-btn");
821
+ const refreshBtn = document.getElementById("refresh-btn");
822
+ const realtimeStreamToggle = document.getElementById("realtime-stream");
823
+ const initialPlaybackDelayInput = document.getElementById("initial-playback-delay-seconds");
824
+
825
+ let currentAudioObjectUrl = null;
826
+ let currentStreamId = null;
827
+ let currentStreamAbortController = null;
828
+ let currentStreamStatusTimer = null;
829
+ let currentAudioContext = null;
830
+ let nextPlaybackTime = 0;
831
+ let currentInitialPlaybackDelaySeconds = 0.08;
832
+ let currentRealtimePlaybackPaused = false;
833
+ let currentRealtimePlaybackCompletionTimer = null;
834
+ let playbackChunks = [];
835
+ let bufferedPlaybackBoundaries = [];
836
+ let currentPlaybackChunkIndex = null;
837
+ let currentPlaybackMarkedComplete = false;
838
+ let currentPromptAudioPreviewUrl = null;
839
+ let currentRealtimePlaybackStartAt = null;
840
+ let currentRealtimePlaybackScheduledAudioSeconds = 0;
841
+ let currentRealtimePlaybackChunkRanges = [];
842
+
843
+ const demosById = new Map();
844
+ for (const demo of DEMOS) {
845
+ demosById.set(demo.id, demo);
846
+ const option = document.createElement("option");
847
+ option.value = demo.id;
848
+ option.textContent = demo.name;
849
+ if (demo.id === DEFAULT_DEMO_ID) {
850
+ option.selected = true;
851
+ }
852
+ demoSelect.appendChild(option);
853
+ }
854
+ document.getElementById("attn-implementation").value = DEFAULT_ATTN_IMPLEMENTATION;
855
+
856
+ function getSelectedDemo() {
857
+ return demosById.get(demoSelect.value) || DEMOS[0] || null;
858
+ }
859
+
860
+ function getUploadedPromptAudioFile() {
861
+ const files = promptAudioUploadInput.files;
862
+ return files && files.length > 0 ? files[0] : null;
863
+ }
864
+
865
+ function clearPromptAudioPreviewUrl() {
866
+ if (!currentPromptAudioPreviewUrl) {
867
+ return;
868
+ }
869
+ URL.revokeObjectURL(currentPromptAudioPreviewUrl);
870
+ currentPromptAudioPreviewUrl = null;
871
+ }
872
+
873
+ function getDemoPromptAudioUrl(demoId) {
874
+ return `${APP_BASE}/api/demo-prompt-audio/${encodeURIComponent(demoId)}`;
875
+ }
876
+
877
+ function showPromptAudioFilePicker(message = "选择文件 | 未选择任何文件") {
878
+ promptAudioPreview.pause();
879
+ promptAudioPreview.removeAttribute("src");
880
+ promptAudioPreview.load();
881
+ promptAudioPreview.hidden = true;
882
+ promptAudioUploadInput.hidden = false;
883
+ choosePromptAudioBtn.hidden = true;
884
+ clearPromptAudioBtn.hidden = true;
885
+ promptAudioSource.textContent = message;
886
+ }
887
+
888
+ function showPromptAudioPreview({
889
+ sourceUrl,
890
+ message,
891
+ showResetToDemo,
892
+ }) {
893
+ promptAudioPreview.src = sourceUrl;
894
+ promptAudioPreview.hidden = false;
895
+ promptAudioUploadInput.hidden = true;
896
+ choosePromptAudioBtn.hidden = false;
897
+ clearPromptAudioBtn.hidden = !showResetToDemo;
898
+ promptAudioSource.textContent = message;
899
+ }
900
+
901
+ function updatePromptAudioPanel(demo = getSelectedDemo()) {
902
+ const uploadedPromptAudio = getUploadedPromptAudioFile();
903
+ if (uploadedPromptAudio) {
904
+ clearPromptAudioPreviewUrl();
905
+ currentPromptAudioPreviewUrl = URL.createObjectURL(uploadedPromptAudio);
906
+ showPromptAudioPreview({
907
+ sourceUrl: currentPromptAudioPreviewUrl,
908
+ message: `Using uploaded prompt speech: ${uploadedPromptAudio.name}`,
909
+ showResetToDemo: true,
910
+ });
911
+ return;
912
+ }
913
+
914
+ clearPromptAudioPreviewUrl();
915
+
916
+ if (demo && demo.id) {
917
+ showPromptAudioPreview({
918
+ sourceUrl: getDemoPromptAudioUrl(demo.id),
919
+ message: demo.prompt_speech
920
+ ? `Using demo prompt speech: ${demo.prompt_speech}`
921
+ : "Using demo prompt speech.",
922
+ showResetToDemo: false,
923
+ });
924
+ return;
925
+ }
926
+
927
+ showPromptAudioFilePicker();
928
+ }
929
+
930
+ function applySelectedDemo(replaceText = true) {
931
+ const demo = getSelectedDemo();
932
+ const uploadedPromptAudio = getUploadedPromptAudioFile();
933
+
934
+ if (!demo && !uploadedPromptAudio) {
935
+ resolvedPrompt.textContent = "";
936
+ if (replaceText) {
937
+ textInput.value = "";
938
+ }
939
+ updatePromptAudioPanel(null);
940
+ renderPlaybackScript([], textInput.value);
941
+ generateBtn.disabled = true;
942
+ return;
943
+ }
944
+
945
+ if (demo && replaceText) {
946
+ textInput.value = demo.text || "";
947
+ }
948
+ updatePromptAudioPanel(demo);
949
+ previewPlaybackScriptFromInputs();
950
+ generateBtn.disabled = false;
951
+ }
952
+
953
+ function setStatus(node, text, isError = false) {
954
+ node.textContent = text;
955
+ node.classList.toggle("error", Boolean(isError));
956
+ }
957
+
958
+ function clearAudioOutput() {
959
+ if (currentAudioObjectUrl) {
960
+ URL.revokeObjectURL(currentAudioObjectUrl);
961
+ currentAudioObjectUrl = null;
962
+ }
963
+ audioOutput.pause();
964
+ audioOutput.removeAttribute("src");
965
+ audioOutput.load();
966
+ bufferedPlaybackBoundaries = [];
967
+ currentPlaybackMarkedComplete = false;
968
+ setPlaybackHighlight(null);
969
+ updatePauseButtonState();
970
+ }
971
+
972
+ function clearNormalizedOutputs() {
973
+ normalizedTextOutput.value = "";
974
+ }
975
+
976
+ function updateNormalizedOutputs(payload) {
977
+ normalizedTextOutput.value = payload.normalized_text || "";
978
+ }
979
+
980
+ function splitTextForDisplay(text) {
981
+ const normalizedText = String(text || "").trim();
982
+ if (!normalizedText) {
983
+ return [];
984
+ }
985
+
986
+ const segments = [];
987
+ for (const rawLine of normalizedText.split(/\\n+/)) {
988
+ const line = rawLine.trim();
989
+ if (!line) {
990
+ continue;
991
+ }
992
+ const parts = line.match(/[^。!?!?;;.\\n]+(?:[。!?!?;;]+|\\.(?=\\s|$)|$)/g);
993
+ if (parts && parts.length > 1) {
994
+ for (const part of parts) {
995
+ const cleanedPart = part.trim();
996
+ if (cleanedPart) {
997
+ segments.push(cleanedPart);
998
+ }
999
+ }
1000
+ continue;
1001
+ }
1002
+ segments.push(line);
1003
+ }
1004
+ return segments.length ? segments : [normalizedText];
1005
+ }
1006
+
1007
+ function normalizePlaybackChunks(chunks, fallbackText = "") {
1008
+ if (Array.isArray(chunks)) {
1009
+ const normalizedChunks = chunks
1010
+ .map((chunk) => String(chunk || "").trim())
1011
+ .filter(Boolean);
1012
+ if (normalizedChunks.length > 0) {
1013
+ return normalizedChunks;
1014
+ }
1015
+ }
1016
+ return splitTextForDisplay(fallbackText);
1017
+ }
1018
+
1019
+ function rebuildBufferedPlaybackBoundaries() {
1020
+ bufferedPlaybackBoundaries = [];
1021
+ if (!playbackChunks.length) {
1022
+ return;
1023
+ }
1024
+ const duration = Number(audioOutput.duration);
1025
+ if (!Number.isFinite(duration) || duration <= 0) {
1026
+ return;
1027
+ }
1028
+
1029
+ const weights = playbackChunks.map((chunk) => {
1030
+ const compactChunk = String(chunk).replace(/\\s+/g, "");
1031
+ return Math.max(1, Array.from(compactChunk).length);
1032
+ });
1033
+ const totalWeight = weights.reduce((sum, value) => sum + value, 0);
1034
+ let accumulatedSeconds = 0;
1035
+ bufferedPlaybackBoundaries = weights.map((weight) => {
1036
+ accumulatedSeconds += (duration * weight) / totalWeight;
1037
+ return accumulatedSeconds;
1038
+ });
1039
+ }
1040
+
1041
+ function setPlaybackHighlight(activeIndex, options = {}) {
1042
+ const markAllPlayed = Boolean(options.markAllPlayed);
1043
+ const segmentNodes = playbackScript.querySelectorAll(".playback-segment");
1044
+ let normalizedActiveIndex = null;
1045
+ if (segmentNodes.length > 0 && activeIndex !== null && activeIndex !== undefined) {
1046
+ const numericIndex = Number(activeIndex);
1047
+ if (Number.isFinite(numericIndex)) {
1048
+ normalizedActiveIndex = Math.max(0, Math.min(segmentNodes.length - 1, Math.trunc(numericIndex)));
1049
+ }
1050
+ }
1051
+
1052
+ const shouldScroll =
1053
+ normalizedActiveIndex !== null &&
1054
+ normalizedActiveIndex !== currentPlaybackChunkIndex &&
1055
+ !markAllPlayed;
1056
+
1057
+ segmentNodes.forEach((node, index) => {
1058
+ const isActive = normalizedActiveIndex !== null && index === normalizedActiveIndex && !markAllPlayed;
1059
+ const isPlayed = markAllPlayed || (normalizedActiveIndex !== null && index < normalizedActiveIndex);
1060
+ node.classList.toggle("active", isActive);
1061
+ node.classList.toggle("played", isPlayed);
1062
+ });
1063
+
1064
+ if (shouldScroll) {
1065
+ segmentNodes[normalizedActiveIndex].scrollIntoView({
1066
+ block: "nearest",
1067
+ inline: "nearest",
1068
+ behavior: "smooth"
1069
+ });
1070
+ }
1071
+
1072
+ currentPlaybackChunkIndex = normalizedActiveIndex;
1073
+ currentPlaybackMarkedComplete = markAllPlayed;
1074
+ }
1075
+
1076
+ function renderPlaybackScript(chunks, fallbackText = "") {
1077
+ playbackChunks = normalizePlaybackChunks(chunks, fallbackText);
1078
+ bufferedPlaybackBoundaries = [];
1079
+ currentPlaybackChunkIndex = null;
1080
+ currentPlaybackMarkedComplete = false;
1081
+ playbackScript.innerHTML = "";
1082
+
1083
+ if (!playbackChunks.length) {
1084
+ playbackScript.classList.add("empty");
1085
+ playbackScript.textContent = "The current sentence will be highlighted here during playback.";
1086
+ return;
1087
+ }
1088
+
1089
+ playbackScript.classList.remove("empty");
1090
+ for (const chunk of playbackChunks) {
1091
+ const segmentNode = document.createElement("span");
1092
+ segmentNode.className = "playback-segment";
1093
+ segmentNode.textContent = chunk;
1094
+ playbackScript.appendChild(segmentNode);
1095
+ }
1096
+ rebuildBufferedPlaybackBoundaries();
1097
+ setPlaybackHighlight(null);
1098
+ }
1099
+
1100
+ function updateBufferedPlaybackHighlight() {
1101
+ if (hasRealtimePlayback()) {
1102
+ return;
1103
+ }
1104
+ if (!hasBufferedPlayback() || !playbackChunks.length || currentPlaybackMarkedComplete) {
1105
+ return;
1106
+ }
1107
+ if (audioOutput.ended) {
1108
+ setPlaybackHighlight(null, { markAllPlayed: true });
1109
+ return;
1110
+ }
1111
+ if (!Number.isFinite(audioOutput.duration) || audioOutput.duration <= 0) {
1112
+ if (!audioOutput.paused) {
1113
+ setPlaybackHighlight(0);
1114
+ }
1115
+ return;
1116
+ }
1117
+ if (!bufferedPlaybackBoundaries.length) {
1118
+ rebuildBufferedPlaybackBoundaries();
1119
+ }
1120
+ if (!bufferedPlaybackBoundaries.length) {
1121
+ return;
1122
+ }
1123
+
1124
+ const clampedCurrentTime = Math.max(0, Math.min(Number(audioOutput.currentTime || 0), Number(audioOutput.duration)));
1125
+ let activeIndex = bufferedPlaybackBoundaries.findIndex((boundary) => clampedCurrentTime <= boundary);
1126
+ if (activeIndex < 0) {
1127
+ activeIndex = playbackChunks.length - 1;
1128
+ }
1129
+ setPlaybackHighlight(activeIndex);
1130
+ }
1131
+
1132
+ function previewPlaybackScriptFromInputs() {
1133
+ if (hasBufferedPlayback() || hasRealtimePlayback()) {
1134
+ return;
1135
+ }
1136
+ renderPlaybackScript([], textInput.value);
1137
+ }
1138
+
1139
+ function hasBufferedPlayback() {
1140
+ return Boolean(audioOutput.getAttribute("src"));
1141
+ }
1142
+
1143
+ function hasRealtimePlayback() {
1144
+ return Boolean(currentAudioContext);
1145
+ }
1146
+
1147
+ function updatePauseButtonState() {
1148
+ if (hasRealtimePlayback()) {
1149
+ pauseBtn.disabled = false;
1150
+ pauseBtn.textContent = currentRealtimePlaybackPaused ? "Resume Playback" : "Pause Playback";
1151
+ return;
1152
+ }
1153
+ if (hasBufferedPlayback()) {
1154
+ pauseBtn.disabled = false;
1155
+ pauseBtn.textContent = audioOutput.paused ? "Resume Playback" : "Pause Playback";
1156
+ return;
1157
+ }
1158
+ pauseBtn.disabled = true;
1159
+ pauseBtn.textContent = "Pause Playback";
1160
+ }
1161
+
1162
+ function resetRealtimePlaybackTracking() {
1163
+ currentRealtimePlaybackStartAt = null;
1164
+ currentRealtimePlaybackScheduledAudioSeconds = 0;
1165
+ currentRealtimePlaybackChunkRanges = [];
1166
+ }
1167
+
1168
+ function normalizeRealtimeChunkRanges(rawRanges) {
1169
+ if (!Array.isArray(rawRanges)) {
1170
+ return [];
1171
+ }
1172
+ const normalizedRanges = [];
1173
+ for (const rawRange of rawRanges) {
1174
+ if (!Array.isArray(rawRange) || rawRange.length < 3) {
1175
+ continue;
1176
+ }
1177
+ const startSeconds = Number(rawRange[0]);
1178
+ const endSeconds = Number(rawRange[1]);
1179
+ const chunkIndex = Number(rawRange[2]);
1180
+ if (!Number.isFinite(startSeconds) || !Number.isFinite(endSeconds) || !Number.isFinite(chunkIndex)) {
1181
+ continue;
1182
+ }
1183
+ normalizedRanges.push([
1184
+ Math.max(0, startSeconds),
1185
+ Math.max(0, endSeconds),
1186
+ Math.max(0, Math.trunc(chunkIndex))
1187
+ ]);
1188
+ }
1189
+ normalizedRanges.sort((a, b) => a[1] - b[1]);
1190
+ return normalizedRanges;
1191
+ }
1192
+
1193
+ function resolveRealtimeChunkIndexByPlaybackSeconds(playbackSeconds) {
1194
+ if (!currentRealtimePlaybackChunkRanges.length) {
1195
+ return null;
1196
+ }
1197
+ const clampedSeconds = Math.max(0, Number(playbackSeconds) || 0);
1198
+ for (const [, endSeconds, chunkIndex] of currentRealtimePlaybackChunkRanges) {
1199
+ if (clampedSeconds <= endSeconds + 1e-6) {
1200
+ return chunkIndex;
1201
+ }
1202
+ }
1203
+ return currentRealtimePlaybackChunkRanges[currentRealtimePlaybackChunkRanges.length - 1][2];
1204
+ }
1205
+
1206
+ function updateRealtimePlaybackHighlightFromLocalClock() {
1207
+ if (!currentAudioContext || currentRealtimePlaybackPaused) {
1208
+ return;
1209
+ }
1210
+ if (currentRealtimePlaybackStartAt === null || !currentRealtimePlaybackChunkRanges.length) {
1211
+ return;
1212
+ }
1213
+ const elapsedPlaybackSeconds = Math.max(0, currentAudioContext.currentTime - currentRealtimePlaybackStartAt);
1214
+ const clampedPlaybackSeconds = Math.min(
1215
+ elapsedPlaybackSeconds,
1216
+ currentRealtimePlaybackScheduledAudioSeconds > 0
1217
+ ? currentRealtimePlaybackScheduledAudioSeconds
1218
+ : elapsedPlaybackSeconds
1219
+ );
1220
+ const chunkIndex = resolveRealtimeChunkIndexByPlaybackSeconds(clampedPlaybackSeconds);
1221
+ if (chunkIndex !== null && chunkIndex !== undefined) {
1222
+ setPlaybackHighlight(chunkIndex);
1223
+ }
1224
+ }
1225
+
1226
+ function base64ToBlob(base64Value, mimeType) {
1227
+ const binary = atob(base64Value);
1228
+ const bytes = new Uint8Array(binary.length);
1229
+ for (let i = 0; i < binary.length; i += 1) {
1230
+ bytes[i] = binary.charCodeAt(i);
1231
+ }
1232
+ return new Blob([bytes], { type: mimeType });
1233
+ }
1234
+
1235
+ async function fetchJson(url, options) {
1236
+ const response = await fetch(url, options);
1237
+ const text = await response.text();
1238
+ if (!response.ok) {
1239
+ throw new Error(text || `HTTP ${response.status}`);
1240
+ }
1241
+ return text ? JSON.parse(text) : {};
1242
+ }
1243
+
1244
+ function buildFormData() {
1245
+ const demo = getSelectedDemo();
1246
+ const uploadedPromptAudio = getUploadedPromptAudioFile();
1247
+ const formData = new FormData();
1248
+ formData.append("text", textInput.value);
1249
+ if (demo) {
1250
+ formData.append("demo_id", demo.id);
1251
+ }
1252
+ if (uploadedPromptAudio) {
1253
+ formData.append("prompt_audio", uploadedPromptAudio);
1254
+ }
1255
+ formData.append("max_new_frames", document.getElementById("max-new-frames").value);
1256
+ formData.append("voice_clone_max_text_tokens", document.getElementById("voice-clone-max-text-tokens").value);
1257
+ formData.append("attn_implementation", document.getElementById("attn-implementation").value);
1258
+ formData.append("do_sample", document.getElementById("do-sample").checked ? "1" : "0");
1259
+ formData.append("text_temperature", document.getElementById("text-temperature").value);
1260
+ formData.append("text_top_p", document.getElementById("text-top-p").value);
1261
+ formData.append("text_top_k", document.getElementById("text-top-k").value);
1262
+ formData.append("audio_temperature", document.getElementById("audio-temperature").value);
1263
+ formData.append("audio_top_p", document.getElementById("audio-top-p").value);
1264
+ formData.append("audio_top_k", document.getElementById("audio-top-k").value);
1265
+ formData.append("audio_repetition_penalty", document.getElementById("audio-repetition-penalty").value);
1266
+ formData.append("seed", document.getElementById("seed").value);
1267
+ formData.append("tts_max_batch_size", document.getElementById("tts-max-batch-size").value);
1268
+ formData.append("codec_max_batch_size", document.getElementById("codec-max-batch-size").value);
1269
+ formData.append("enable_text_normalization", document.getElementById("enable-text-normalization").checked ? "1" : "0");
1270
+ formData.append("enable_normalize_tts_text", document.getElementById("enable-robust-text-normalization").checked ? "1" : "0");
1271
+ formData.append("cpu_threads", document.getElementById("cpu-thread-count").value || String(DEFAULT_CPU_THREADS));
1272
+ return formData;
1273
+ }
1274
+
1275
+ function mergeUint8Arrays(a, b) {
1276
+ const merged = new Uint8Array(a.length + b.length);
1277
+ merged.set(a, 0);
1278
+ merged.set(b, a.length);
1279
+ return merged;
1280
+ }
1281
+
1282
+ function resolveInitialPlaybackDelaySeconds() {
1283
+ const rawValue = Number(initialPlaybackDelayInput.value);
1284
+ if (!Number.isFinite(rawValue)) {
1285
+ return 0.08;
1286
+ }
1287
+ return Math.max(0.0, rawValue);
1288
+ }
1289
+
1290
+ function schedulePcmChunk(pcmChunk, sampleRate, channels) {
1291
+ if (!currentAudioContext || pcmChunk.byteLength <= 0) {
1292
+ return;
1293
+ }
1294
+ const bytesPerFrame = channels * 2;
1295
+ const totalFrames = Math.floor(pcmChunk.byteLength / bytesPerFrame);
1296
+ if (totalFrames <= 0) {
1297
+ return;
1298
+ }
1299
+
1300
+ const audioBuffer = currentAudioContext.createBuffer(channels, totalFrames, sampleRate);
1301
+ const view = new DataView(pcmChunk.buffer, pcmChunk.byteOffset, totalFrames * bytesPerFrame);
1302
+ for (let channelIndex = 0; channelIndex < channels; channelIndex += 1) {
1303
+ const channelData = audioBuffer.getChannelData(channelIndex);
1304
+ for (let frameIndex = 0; frameIndex < totalFrames; frameIndex += 1) {
1305
+ const byteOffset = (frameIndex * channels + channelIndex) * 2;
1306
+ channelData[frameIndex] = view.getInt16(byteOffset, true) / 32768.0;
1307
+ }
1308
+ }
1309
+
1310
+ const source = currentAudioContext.createBufferSource();
1311
+ source.buffer = audioBuffer;
1312
+ source.connect(currentAudioContext.destination);
1313
+ const now = currentAudioContext.currentTime;
1314
+ const startAt = Math.max(nextPlaybackTime || (now + currentInitialPlaybackDelaySeconds), now + 0.02);
1315
+ source.start(startAt);
1316
+ const endAt = startAt + audioBuffer.duration;
1317
+ nextPlaybackTime = endAt;
1318
+ if (currentRealtimePlaybackStartAt === null) {
1319
+ currentRealtimePlaybackStartAt = startAt;
1320
+ }
1321
+ currentRealtimePlaybackScheduledAudioSeconds = Math.max(
1322
+ currentRealtimePlaybackScheduledAudioSeconds,
1323
+ endAt - currentRealtimePlaybackStartAt
1324
+ );
1325
+ }
1326
+
1327
+ function clearRealtimePlaybackCompletionTimer() {
1328
+ if (currentRealtimePlaybackCompletionTimer) {
1329
+ window.clearTimeout(currentRealtimePlaybackCompletionTimer);
1330
+ currentRealtimePlaybackCompletionTimer = null;
1331
+ }
1332
+ }
1333
+
1334
+ function monitorRealtimePlaybackCompletion() {
1335
+ clearRealtimePlaybackCompletionTimer();
1336
+ if (!currentAudioContext) {
1337
+ return;
1338
+ }
1339
+
1340
+ async function pollRealtimePlaybackCompletion() {
1341
+ if (!currentAudioContext) {
1342
+ return;
1343
+ }
1344
+ if (currentRealtimePlaybackPaused) {
1345
+ currentRealtimePlaybackCompletionTimer = window.setTimeout(() => {
1346
+ pollRealtimePlaybackCompletion().catch(() => {});
1347
+ }, 120);
1348
+ return;
1349
+ }
1350
+
1351
+ updateRealtimePlaybackHighlightFromLocalClock();
1352
+
1353
+ const remainingSeconds = nextPlaybackTime - currentAudioContext.currentTime;
1354
+ if (remainingSeconds > 0.05) {
1355
+ currentRealtimePlaybackCompletionTimer = window.setTimeout(() => {
1356
+ pollRealtimePlaybackCompletion().catch(() => {});
1357
+ }, 120);
1358
+ return;
1359
+ }
1360
+
1361
+ setPlaybackHighlight(null, { markAllPlayed: true });
1362
+ try {
1363
+ await currentAudioContext.close();
1364
+ } catch (error) {
1365
+ }
1366
+ currentAudioContext = null;
1367
+ currentRealtimePlaybackPaused = false;
1368
+ nextPlaybackTime = 0;
1369
+ resetRealtimePlaybackTracking();
1370
+ updatePauseButtonState();
1371
+ }
1372
+
1373
+ currentRealtimePlaybackCompletionTimer = window.setTimeout(() => {
1374
+ pollRealtimePlaybackCompletion().catch(() => {});
1375
+ }, 120);
1376
+ }
1377
+
1378
+ async function closeRealtimeStream() {
1379
+ clearRealtimePlaybackCompletionTimer();
1380
+ if (currentStreamStatusTimer) {
1381
+ window.clearInterval(currentStreamStatusTimer);
1382
+ currentStreamStatusTimer = null;
1383
+ }
1384
+ if (currentStreamAbortController) {
1385
+ currentStreamAbortController.abort();
1386
+ currentStreamAbortController = null;
1387
+ }
1388
+ if (currentStreamId) {
1389
+ fetch(`${APP_BASE}/api/generate-stream/${encodeURIComponent(currentStreamId)}/close`, {
1390
+ method: "POST"
1391
+ }).catch(() => {});
1392
+ currentStreamId = null;
1393
+ }
1394
+ if (currentAudioContext) {
1395
+ try {
1396
+ await currentAudioContext.close();
1397
+ } catch (error) {
1398
+ }
1399
+ currentAudioContext = null;
1400
+ }
1401
+ nextPlaybackTime = 0;
1402
+ currentRealtimePlaybackPaused = false;
1403
+ resetRealtimePlaybackTracking();
1404
+ currentPlaybackMarkedComplete = false;
1405
+ setPlaybackHighlight(null);
1406
+ updatePauseButtonState();
1407
+ }
1408
+
1409
+ async function refreshWarmupStatus() {
1410
+ try {
1411
+ const [warmupData, normalizationData] = await Promise.all([
1412
+ fetchJson(`${APP_BASE}/api/warmup-status`),
1413
+ fetchJson(`${APP_BASE}/api/text-normalization-status`),
1414
+ ]);
1415
+ setStatus(warmupStatus, warmupData.status_text || "Unknown status.");
1416
+ setStatus(
1417
+ textNormalizationStatus,
1418
+ normalizationData.status_text || "Unknown status.",
1419
+ Boolean(normalizationData.failed)
1420
+ );
1421
+ } catch (error) {
1422
+ setStatus(warmupStatus, String(error), true);
1423
+ setStatus(textNormalizationStatus, String(error), true);
1424
+ }
1425
+ }
1426
+
1427
+ async function generateBuffered(formData) {
1428
+ clearAudioOutput();
1429
+ streamMetrics.textContent = "";
1430
+ const data = await fetchJson(`${APP_BASE}/api/generate`, {
1431
+ method: "POST",
1432
+ body: formData
1433
+ });
1434
+
1435
+ updateNormalizedOutputs(data);
1436
+ renderPlaybackScript(data.text_chunks, data.normalized_text || textInput.value);
1437
+ const audioBlob = base64ToBlob(data.audio_base64, "audio/wav");
1438
+ currentAudioObjectUrl = URL.createObjectURL(audioBlob);
1439
+ audioOutput.src = currentAudioObjectUrl;
1440
+ audioOutput.play().catch(() => {});
1441
+ rebuildBufferedPlaybackBoundaries();
1442
+ updateBufferedPlaybackHighlight();
1443
+ updatePauseButtonState();
1444
+ resolvedPrompt.textContent = "Generated speech is ready.";
1445
+ setStatus(runStatus, data.run_status || "Done.");
1446
+ if (data.warmup_status_text) {
1447
+ setStatus(warmupStatus, data.warmup_status_text);
1448
+ }
1449
+ if (data.text_normalization_status_text) {
1450
+ setStatus(textNormalizationStatus, data.text_normalization_status_text);
1451
+ }
1452
+ }
1453
+
1454
+ async function generateRealtime(formData) {
1455
+ await closeRealtimeStream();
1456
+ clearAudioOutput();
1457
+ resolvedPrompt.textContent = "Generating realtime speech...";
1458
+ streamMetrics.textContent = "";
1459
+
1460
+ const startData = await fetchJson(`${APP_BASE}/api/generate-stream/start`, {
1461
+ method: "POST",
1462
+ body: formData
1463
+ });
1464
+
1465
+ currentStreamId = startData.stream_id;
1466
+ setStatus(runStatus, startData.run_status || "Streaming realtime audio...");
1467
+ if (startData.warmup_status_text) {
1468
+ setStatus(warmupStatus, startData.warmup_status_text);
1469
+ }
1470
+ if (startData.text_normalization_status_text) {
1471
+ setStatus(textNormalizationStatus, startData.text_normalization_status_text);
1472
+ }
1473
+ updateNormalizedOutputs(startData);
1474
+ renderPlaybackScript(startData.text_chunks, startData.normalized_text || textInput.value);
1475
+
1476
+ const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
1477
+ if (!AudioContextCtor) {
1478
+ throw new Error("This browser does not support Web Audio streaming playback.");
1479
+ }
1480
+ currentInitialPlaybackDelaySeconds = resolveInitialPlaybackDelaySeconds();
1481
+ currentAudioContext = new AudioContextCtor({ sampleRate: startData.sample_rate });
1482
+ await currentAudioContext.resume();
1483
+ nextPlaybackTime = currentAudioContext.currentTime + currentInitialPlaybackDelaySeconds;
1484
+ currentRealtimePlaybackPaused = false;
1485
+ resetRealtimePlaybackTracking();
1486
+ clearRealtimePlaybackCompletionTimer();
1487
+ updatePauseButtonState();
1488
+ currentStreamAbortController = new AbortController();
1489
+
1490
+ async function updateStreamStatus() {
1491
+ if (!currentStreamId) {
1492
+ return null;
1493
+ }
1494
+ const snapshot = await fetchJson(startData.status_url);
1495
+ if (snapshot.run_status) {
1496
+ setStatus(runStatus, snapshot.run_status, Boolean(snapshot.failed));
1497
+ }
1498
+ const metrics = [
1499
+ `state=${snapshot.state}`,
1500
+ `emitted=${Number(snapshot.emitted_audio_seconds || 0).toFixed(2)}s`,
1501
+ `lead=${Number(snapshot.lead_seconds || 0).toFixed(2)}s`,
1502
+ `playback_delay=${currentInitialPlaybackDelaySeconds.toFixed(2)}s`
1503
+ ];
1504
+ if (snapshot.first_audio_latency_seconds !== null && snapshot.first_audio_latency_seconds !== undefined) {
1505
+ metrics.push(`first_audio=${Number(snapshot.first_audio_latency_seconds).toFixed(2)}s`);
1506
+ }
1507
+ streamMetrics.textContent = metrics.join(" | ");
1508
+ if (!currentRealtimePlaybackPaused && snapshot.playback_chunk_index !== null && snapshot.playback_chunk_index !== undefined) {
1509
+ setPlaybackHighlight(snapshot.playback_chunk_index);
1510
+ }
1511
+ return snapshot;
1512
+ }
1513
+
1514
+ currentStreamStatusTimer = window.setInterval(() => {
1515
+ updateStreamStatus().catch(() => {});
1516
+ }, 500);
1517
+ await updateStreamStatus();
1518
+
1519
+ const response = await fetch(startData.audio_url, {
1520
+ signal: currentStreamAbortController.signal
1521
+ });
1522
+ if (!response.ok) {
1523
+ const text = await response.text();
1524
+ throw new Error(text || `HTTP ${response.status}`);
1525
+ }
1526
+ if (!response.body) {
1527
+ throw new Error("ReadableStream is not available on this response.");
1528
+ }
1529
+
1530
+ const reader = response.body.getReader();
1531
+ const channels = Number(startData.channels || 2);
1532
+ const sampleRate = Number(startData.sample_rate || 48000);
1533
+ const bytesPerFrame = channels * 2;
1534
+ let remainder = new Uint8Array(0);
1535
+
1536
+ while (true) {
1537
+ const { done, value } = await reader.read();
1538
+ if (done) {
1539
+ break;
1540
+ }
1541
+ if (!value || value.length === 0) {
1542
+ continue;
1543
+ }
1544
+ const merged = mergeUint8Arrays(remainder, value);
1545
+ const alignedLength = Math.floor(merged.length / bytesPerFrame) * bytesPerFrame;
1546
+ if (alignedLength <= 0) {
1547
+ remainder = merged;
1548
+ continue;
1549
+ }
1550
+ schedulePcmChunk(merged.subarray(0, alignedLength), sampleRate, channels);
1551
+ remainder = merged.subarray(alignedLength);
1552
+ }
1553
+
1554
+ await updateStreamStatus();
1555
+ if (currentStreamStatusTimer) {
1556
+ window.clearInterval(currentStreamStatusTimer);
1557
+ currentStreamStatusTimer = null;
1558
+ }
1559
+ let result = null;
1560
+ for (let attempt = 0; attempt < 40; attempt += 1) {
1561
+ result = await fetchJson(startData.result_url);
1562
+ if (result.ready) {
1563
+ break;
1564
+ }
1565
+ await new Promise((resolve) => window.setTimeout(resolve, 100));
1566
+ }
1567
+ if (!result || !result.ready) {
1568
+ throw new Error("Streaming finished but the final result is not ready yet.");
1569
+ }
1570
+ if (result.audio_base64) {
1571
+ if (currentAudioObjectUrl) {
1572
+ URL.revokeObjectURL(currentAudioObjectUrl);
1573
+ currentAudioObjectUrl = null;
1574
+ }
1575
+ const audioBlob = base64ToBlob(result.audio_base64, "audio/wav");
1576
+ currentAudioObjectUrl = URL.createObjectURL(audioBlob);
1577
+ audioOutput.src = currentAudioObjectUrl;
1578
+ audioOutput.load();
1579
+ rebuildBufferedPlaybackBoundaries();
1580
+ }
1581
+ resolvedPrompt.textContent = "Generated speech is ready.";
1582
+ streamMetrics.textContent = result.stream_metrics || streamMetrics.textContent;
1583
+ if (Array.isArray(result.text_chunks) && result.text_chunks.length > 0 && playbackChunks.length === 0) {
1584
+ renderPlaybackScript(result.text_chunks, normalizedTextOutput.value || textInput.value);
1585
+ }
1586
+ currentRealtimePlaybackChunkRanges = normalizeRealtimeChunkRanges(result.audio_chunk_ranges);
1587
+ updateRealtimePlaybackHighlightFromLocalClock();
1588
+ setStatus(runStatus, result.run_status || "Stream complete.");
1589
+ if (currentStreamId) {
1590
+ fetch(`${APP_BASE}/api/generate-stream/${encodeURIComponent(currentStreamId)}/close`, {
1591
+ method: "POST"
1592
+ }).catch(() => {});
1593
+ currentStreamId = null;
1594
+ }
1595
+ monitorRealtimePlaybackCompletion();
1596
+ updatePauseButtonState();
1597
+ }
1598
+
1599
+ async function togglePausePlayback() {
1600
+ if (hasRealtimePlayback()) {
1601
+ if (currentRealtimePlaybackPaused) {
1602
+ await currentAudioContext.resume();
1603
+ currentRealtimePlaybackPaused = false;
1604
+ updateRealtimePlaybackHighlightFromLocalClock();
1605
+ } else {
1606
+ await currentAudioContext.suspend();
1607
+ currentRealtimePlaybackPaused = true;
1608
+ }
1609
+ updatePauseButtonState();
1610
+ return;
1611
+ }
1612
+
1613
+ if (hasBufferedPlayback()) {
1614
+ if (audioOutput.paused) {
1615
+ await audioOutput.play().catch(() => {});
1616
+ } else {
1617
+ audioOutput.pause();
1618
+ }
1619
+ updatePauseButtonState();
1620
+ }
1621
+ }
1622
+
1623
+ async function generate() {
1624
+ generateBtn.disabled = true;
1625
+ refreshBtn.disabled = true;
1626
+ setStatus(runStatus, realtimeStreamToggle.checked ? "Starting realtime stream..." : "Running synthesis...");
1627
+
1628
+ try {
1629
+ const formData = buildFormData();
1630
+ clearNormalizedOutputs();
1631
+ renderPlaybackScript([], textInput.value);
1632
+ if (realtimeStreamToggle.checked) {
1633
+ await generateRealtime(formData);
1634
+ } else {
1635
+ await closeRealtimeStream();
1636
+ await generateBuffered(formData);
1637
+ }
1638
+ } catch (error) {
1639
+ if (realtimeStreamToggle.checked) {
1640
+ await closeRealtimeStream();
1641
+ }
1642
+ setStatus(runStatus, String(error), true);
1643
+ } finally {
1644
+ generateBtn.disabled = false;
1645
+ refreshBtn.disabled = false;
1646
+ }
1647
+ }
1648
+
1649
+ generateBtn.addEventListener("click", generate);
1650
+ demoSelect.addEventListener("change", async () => {
1651
+ await closeRealtimeStream();
1652
+ clearAudioOutput();
1653
+ applySelectedDemo(true);
1654
+ clearNormalizedOutputs();
1655
+ resolvedPrompt.textContent = "";
1656
+ streamMetrics.textContent = "";
1657
+ setStatus(runStatus, "Idle.");
1658
+ });
1659
+ promptAudioUploadInput.addEventListener("change", () => {
1660
+ applySelectedDemo(false);
1661
+ clearNormalizedOutputs();
1662
+ resolvedPrompt.textContent = "";
1663
+ setStatus(runStatus, "Idle.");
1664
+ });
1665
+ choosePromptAudioBtn.addEventListener("click", () => {
1666
+ promptAudioUploadInput.click();
1667
+ });
1668
+ clearPromptAudioBtn.addEventListener("click", () => {
1669
+ promptAudioUploadInput.value = "";
1670
+ applySelectedDemo(false);
1671
+ clearNormalizedOutputs();
1672
+ resolvedPrompt.textContent = "";
1673
+ setStatus(runStatus, "Idle.");
1674
+ });
1675
+ pauseBtn.addEventListener("click", () => {
1676
+ togglePausePlayback().catch((error) => {
1677
+ setStatus(runStatus, String(error), true);
1678
+ });
1679
+ });
1680
+ refreshBtn.addEventListener("click", refreshWarmupStatus);
1681
+ textInput.addEventListener("input", previewPlaybackScriptFromInputs);
1682
+ audioOutput.addEventListener("loadedmetadata", () => {
1683
+ rebuildBufferedPlaybackBoundaries();
1684
+ updateBufferedPlaybackHighlight();
1685
+ });
1686
+ audioOutput.addEventListener("timeupdate", updateBufferedPlaybackHighlight);
1687
+ audioOutput.addEventListener("play", () => {
1688
+ currentPlaybackMarkedComplete = false;
1689
+ updatePauseButtonState();
1690
+ updateBufferedPlaybackHighlight();
1691
+ });
1692
+ audioOutput.addEventListener("pause", updatePauseButtonState);
1693
+ audioOutput.addEventListener("ended", () => {
1694
+ updatePauseButtonState();
1695
+ setPlaybackHighlight(null, { markAllPlayed: true });
1696
+ });
1697
+ window.addEventListener("beforeunload", () => {
1698
+ clearPromptAudioPreviewUrl();
1699
+ });
1700
+ updatePauseButtonState();
1701
+ applySelectedDemo(true);
1702
+ refreshWarmupStatus();
1703
+ window.setInterval(refreshWarmupStatus, 5000);
1704
+ </script>
1705
+ </body>
1706
+ </html>