dsh-live-speak 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -67,8 +67,9 @@ dsh-live-speak
67
67
 
68
68
  | 项 | 默认 | 说明 |
69
69
  |---|---|---|
70
- | 自动播报 | 开 | 关掉后只有手动点播放才念 |
71
- | 音色 | 晓晓 | 下拉选择,即时生效 |
70
+ | 播报引擎 | 线上语音 | 「线上语音」用神经声线,最自然,只走线上;「本地语音」用本机自带声线,不联网、起播最快 |
71
+ | 音色(线上) | 晓晓 | 下拉选择,即时生效 |
72
+ | 声线(本地) | 自动 | 本机装了几条就列几条(Windows 同时认 WinRT 与 SAPI 两套),装了新语音包点「重新检测」即可刷新;纯英文声线念中文会自动换中文声线 |
72
73
  | 语速 | +5% | 合成语速 |
73
74
  | 音量 | +0% | 合成音量(与播放音量分开) |
74
75
  | 一次念多少字 | 120 | 实时模式攒到这个字数送一次合成 |
@@ -85,9 +86,11 @@ dsh-live-speak
85
86
  ## 使用要求与限制
86
87
 
87
88
  - Windows 上的 DSH 桌面端
88
- - 需要联网:语音合成在云端完成,断网或网络策略拦截就没有声音
89
+ - **线上语音档需要联网**:语音合成在云端完成,断网或网络策略拦截就没有声音
90
+ - **本地语音档不需要联网**:走系统自带声线,Windows 用 WinRT / SAPI,macOS 用 `say`,Linux 用 `espeak-ng`。声音不如神经声线自然,但起播最快(单段合成约 0.5 秒)
91
+ - **线上档出问题不会自动换成本地声线**:两档是用户自己选的,线上档合成失败会一直重试到成功,宁可晚几秒开口也不换音色
89
92
  - 不要和 `dsh-big-fish-speak` 同时开自动播报,两个都会念,会重复
90
- - 首次出声有 2~5 秒准备时间,属正常
93
+ - 首次出声有 2~5 秒准备时间,属正常(本地档约 0.5 秒)
91
94
  - 文件名统一念成名词(`a.html` 与 `b.html` 听不出区别);前文已报过数量的同类列表会整段省略
92
95
  - 45 秒提示音只在真的静久了才响,短回合不响
93
96
 
package/RELEASE-NOTES.md CHANGED
@@ -173,9 +173,9 @@ dsh-live-speak
173
173
  ## 六、使用要求
174
174
 
175
175
  - **Windows 上的 DSH 桌面端**
176
- - **要能联网**:语音合成在云端完成,断网或被网络策略拦截就没有声音
176
+ - **线上语音档要能联网**:语音合成在云端完成,断网或被网络策略拦截就没有声音。本地语音档不需要联网,走系统自带声线
177
177
  - **不要和 `dsh-big-fish-speak` 同时开自动播报**:两个插件都会念,会一前一后读两遍。用一个就把另一个的自动播报关掉
178
- - 首次出声有 2~5 秒准备时间,属正常
178
+ - 首次出声有 2~5 秒准备时间,属正常(本地档约 0.5 秒)
179
179
 
180
180
  ---
181
181
 
@@ -202,4 +202,27 @@ dsh-live-speak
202
202
 
203
203
  以后每次更新,会单独附一份「更新说明」,写清这一版新增了什么、修好了什么问题——不需要在这里堆改动记录。
204
204
 
205
- 当前版本:**1.0.0**
205
+ ---
206
+
207
+ ## 1.0.1 更新说明
208
+
209
+ **新增:本地语音档,可选可换**
210
+
211
+ 设置面板「播报引擎」现在是二选一:
212
+
213
+ - **线上语音**:神经声线,最自然。只走线上,不会偷偷换成本机声线。
214
+ - **本地语音**:用系统自带的声线,不联网、零安装,起播最快——单段合成约 0.5 秒。
215
+
216
+ 本地档的声线是**本机有几个就列几个**:Windows 同时读 WinRT 和 SAPI 两套清单,macOS 读系统 `say`,Linux 读 `espeak-ng`。你自己装了新语音包,点右边的「重新检测」就能刷出来,然后在下拉里自己挑。
217
+
218
+ 选到一条念不出中文的纯英文声线也不用担心:那一段会自动换成本机的中文声线重来,不会静默丢掉内容。
219
+
220
+ **修掉的问题**
221
+
222
+ - 本地档的卡片标题写成「晓晓」而实际念的是本机声线——现在显示真正在发声的那条
223
+ - 设置页下拉里两条 `Huihui` 长得一样分不清——现在保留 `Desktop` 后缀区分两个后端
224
+ - 本地合成若返回空音频(只有文件头、时长 0),原来会被当成成功、播放 0 秒等于丢段——现在会检测出来并换声线重试
225
+
226
+ **一条不变的原则**:合成失败不跳过。任何一段合成不出来,就一直重试到成功为止,宁可晚几秒开口,也不让内容缺一段。
227
+
228
+ 当前版本:**1.0.1**
package/client/client.js CHANGED
@@ -86,7 +86,7 @@
86
86
  "settings.auto": "自动播报",
87
87
  "settings.auto.hint": "模型回复时自动念,不用手动触发",
88
88
  "settings.realtime": "实时播报",
89
- "settings.realtime.hint": "边生成边念,第一句比整段写完早十几秒出声",
89
+ "settings.realtime.hint": "模型流式输出时边写边念,不等整段写完;非流式模型则在整段到达后立刻开念",
90
90
  "settings.interrupt": "新回复打断旧内容",
91
91
  "settings.interrupt.hint": "关掉则把旧的念完再念新的",
92
92
  "settings.codeQuiet": "写代码时只念开头和结果",
@@ -99,15 +99,34 @@
99
99
  "settings.dock.sidebar": "左侧导航栏",
100
100
  "settings.dock.sidebar.hint": "窄条贴底",
101
101
  "settings.group.voice": "声音",
102
+ "settings.group.engine": "播报引擎",
103
+ "settings.engine.online": "线上语音",
104
+ "settings.engine.online.hint": "微软神经声线,最自然;只走线上,不会换成本机声线",
105
+ "settings.engine.local": "本地语音",
106
+ "settings.engine.local.hint": "用系统自带声线,不联网、零安装,起播最快",
102
107
  "settings.voice": "声线",
103
108
  "settings.voice.hint": "纯英文声线念中文会自动退回晓晓;想听洋腔中文选带「能念中文」的那两条",
109
+ "settings.voice.localHint": "本机装了几条就列几条,装了新语音包点右边重新检测",
110
+ "settings.voice.localNone": "未检测到可用声线",
111
+ "settings.voice.refresh": "重新检测",
104
112
  "settings.rate": "语速",
105
113
  "settings.rate.hint": "正数更快,负数更慢,范围 -50% ~ +100%",
114
+ "settings.chunk": "段落字数",
115
+ "settings.chunk.hint": "越大越连贯,越小越早出声;本地引擎合成快,长文章可设 200~300",
116
+ "settings.chunk.unit": "字",
106
117
  "settings.volume": "播放音量",
107
118
  "settings.volume.hint": "页面侧实际出声的音量",
108
119
  "settings.preview": "试听",
109
120
  "settings.preview.hint": "用当前声线念一句,确认效果",
110
121
  "settings.preview.btn": "试听",
122
+ "settings.group.eq": "均衡器",
123
+ "settings.eq.flat": "默认",
124
+ "settings.eq.clarity": "人声清晰",
125
+ "settings.eq.soft": "温柔",
126
+ "settings.eq.deep": "浑厚",
127
+ "settings.eq.custom": "自定义",
128
+ "settings.eq.reset": "重置",
129
+ "settings.eq.hint": "拖动滑块立刻生效;只改声音的明暗厚薄,改不了机械语调。",
111
130
  "panel.foot": "声线、语速、音量、播放条位置都能在「设置 → Edge 语音播报」里改。",
112
131
  "voice.default": "晓晓",
113
132
  "preview.default": "你好,我是晓晓。这是试听。"
@@ -154,7 +173,7 @@
154
173
  "settings.auto": "Auto speak",
155
174
  "settings.auto.hint": "Read replies aloud without pressing anything",
156
175
  "settings.realtime": "Live speaking",
157
- "settings.realtime.hint": "Starts on the first sentence, more than ten seconds earlier than reading the finished reply",
176
+ "settings.realtime.hint": "Speaks while the model is still writing when it streams; otherwise starts the moment the full reply arrives",
158
177
  "settings.interrupt": "Interrupt on new reply",
159
178
  "settings.interrupt.hint": "Turn off to finish the old reply before the new one",
160
179
  "settings.codeQuiet": "Quiet while writing code",
@@ -167,15 +186,34 @@
167
186
  "settings.dock.sidebar": "Sidebar row",
168
187
  "settings.dock.sidebar.hint": "A row in the left navigation",
169
188
  "settings.group.voice": "Voice",
189
+ "settings.group.engine": "Speech engine",
190
+ "settings.engine.online": "Online",
191
+ "settings.engine.online.hint": "Microsoft neural voices, the most natural; online only, never switches to a system voice",
192
+ "settings.engine.local": "On this computer",
193
+ "settings.engine.local.hint": "Uses the system voices: no network, nothing to install, starts fastest",
170
194
  "settings.voice": "Voice",
171
195
  "settings.voice.hint": "English-only voices fall back to Xiaoxiao for Chinese; pick a Multilingual voice for Chinese with an accent",
196
+ "settings.voice.localHint": "Every voice installed here is listed; click refresh after installing a new one",
197
+ "settings.voice.localNone": "No usable voice found",
198
+ "settings.voice.refresh": "Refresh",
172
199
  "settings.rate": "Rate",
173
200
  "settings.rate.hint": "Positive is faster, negative is slower, from -50% to +100%",
201
+ "settings.chunk": "Paragraph size",
202
+ "settings.chunk.hint": "Larger is smoother, smaller starts earlier; with the local engine 200–300 is fine for long text",
203
+ "settings.chunk.unit": "chars",
174
204
  "settings.volume": "Playback volume",
175
205
  "settings.volume.hint": "Volume of the audio played in this page",
176
206
  "settings.preview": "Preview",
177
207
  "settings.preview.hint": "Hear the current voice with one sentence",
178
208
  "settings.preview.btn": "Play",
209
+ "settings.group.eq": "Equalizer",
210
+ "settings.eq.flat": "Flat",
211
+ "settings.eq.clarity": "Clarity",
212
+ "settings.eq.soft": "Soft",
213
+ "settings.eq.deep": "Deep",
214
+ "settings.eq.custom": "Custom",
215
+ "settings.eq.reset": "Reset",
216
+ "settings.eq.hint": "Moves take effect instantly. Brightness and body only — robotic cadence stays.",
179
217
  "panel.foot": "Voice, rate, volume and bar position are under Settings → Edge Speech.",
180
218
  "voice.default": "Xiaoxiao",
181
219
  "preview.default": "Hi, this is a preview of the current voice."
@@ -222,7 +260,7 @@
222
260
  "settings.auto": "自動読み上げ",
223
261
  "settings.auto.hint": "返信を自動で読み上げます",
224
262
  "settings.realtime": "リアルタイム読み上げ",
225
- "settings.realtime.hint": "生成しながら読み上げ、最初の一文は完了後の読み上げより十数秒早く始まります",
263
+ "settings.realtime.hint": "モデルがストリーミング出力のときは書きながら読み上げ、そうでなければ全文が届いた直後に始まります",
226
264
  "settings.interrupt": "新しい返信で中断",
227
265
  "settings.interrupt.hint": "オフにすると古い返信を最後まで読み上げてから新しい返信を読みます",
228
266
  "settings.codeQuiet": "コード生成中は静かに",
@@ -235,15 +273,34 @@
235
273
  "settings.dock.sidebar": "左のナビゲーション",
236
274
  "settings.dock.sidebar.hint": "左側のナビゲーションの 1 行",
237
275
  "settings.group.voice": "声",
276
+ "settings.group.engine": "読み上げエンジン",
277
+ "settings.engine.online": "オンライン",
278
+ "settings.engine.online.hint": "Microsoft のニューラル音声で最も自然。オンライン専用で、この PC の音声には切り替わりません",
279
+ "settings.engine.local": "この PC で読み上げ",
280
+ "settings.engine.local.hint": "システム内蔵の音声を使用。ネット不要・追加インストール不要で、最も早く鳴ります",
238
281
  "settings.voice": "声",
239
282
  "settings.voice.hint": "英語専用の声で中国語を読むと Xiaoxiao に戻ります。中国語を読みたい場合は Multilingual の声を選んでください",
283
+ "settings.voice.localHint": "この PC にある音声をすべて表示します。新しい音声を入れたら右の再検出を押してください",
284
+ "settings.voice.localNone": "使用できる音声が見つかりません",
285
+ "settings.voice.refresh": "再検出",
240
286
  "settings.rate": "速度",
241
287
  "settings.rate.hint": "正の値で速く、負の値で遅く、範囲は -50% 〜 +100%",
288
+ "settings.chunk": "段落の文字数",
289
+ "settings.chunk.hint": "大きいほど滑らか、小さいほど早く話し始める。ローカルエンジンなら長文でも 200〜300 が快適",
290
+ "settings.chunk.unit": "字",
242
291
  "settings.volume": "再生音量",
243
292
  "settings.volume.hint": "このページで実際に鳴る音量",
244
293
  "settings.preview": "試聴",
245
294
  "settings.preview.hint": "現在の声で一文を読み上げて確認",
246
295
  "settings.preview.btn": "試聴",
296
+ "settings.group.eq": "イコライザー",
297
+ "settings.eq.flat": "フラット",
298
+ "settings.eq.clarity": "クリア",
299
+ "settings.eq.soft": "ソフト",
300
+ "settings.eq.deep": "低音",
301
+ "settings.eq.custom": "カスタム",
302
+ "settings.eq.reset": "リセット",
303
+ "settings.eq.hint": "スライダーは即時反映。明暗と厚みのみ調整、機械的な抑揚は変わりません。",
247
304
  "panel.foot": "声・速度・音量・表示位置は「設定 → Edge 音声読み上げ」で変更できます。",
248
305
  "voice.default": "Xiaoxiao",
249
306
  "preview.default": "こんにちは。これは現在の声の試聴です。"
@@ -292,14 +349,26 @@
292
349
  * 非中文界面改用插件自带的英文名:拿 settings.voice 去声线表里查。
293
350
  */
294
351
  function voiceNameOf(state) {
295
- var id = state && state.settings ? state.settings.voice : "";
352
+ var s = (state && state.settings) || {};
353
+ // 本地档:真正发声的是系统里的那条声线,宿主已经把它下成
354
+ // voiceName(Huihui Desktop 这种原名)和 voiceLabel(「Huihui·本机」)。
355
+ // 这一支必须放在查表之前,否则会拿 Edge 的 id 查出「晓晓」,
356
+ // 卡片上写着晓晓、耳朵里却是本机声线。
357
+ if (s.ttsEngine === "local") {
358
+ if (UI_LANG === "zh" && s.voiceLabel) return s.voiceLabel;
359
+ var localRaw = String(s.voiceName || s.localVoice || "")
360
+ .replace(/^Microsoft\s+/i, "")
361
+ .replace(/\s+Desktop$/i, "");
362
+ if (localRaw) return localRaw;
363
+ }
364
+ var id = s.voice || "";
296
365
  var list = (manager && manager.voices) || (state && state.voices) || [];
297
366
  for (var i = 0; i < list.length; i++) {
298
367
  if (list[i] && list[i].id === id) {
299
368
  return (UI_LANG === "zh" || !list[i].labelEn) ? list[i].label : list[i].labelEn;
300
369
  }
301
370
  }
302
- var raw = state && state.settings ? state.settings.voiceName : "";
371
+ var raw = s.voiceName || "";
303
372
  return raw || t("voice.default");
304
373
  }
305
374
 
@@ -509,7 +578,20 @@
509
578
  ".dshLiveDockOptionHint{font-size:11px;line-height:1.3;color:var(--dsw-alias-label-tertiary,#8e8e93)}",
510
579
  ".dshLiveSelect{flex:none;height:30px;min-width:170px;max-width:236px;padding:0 8px;border-radius:8px;font:inherit;font-size:12.5px;color:inherit;background:var(--dsw-alias-bg-base,#fff);border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.3));cursor:pointer}",
511
580
  ".dshLiveRange{flex:none;width:186px;margin:0;accent-color:var(--dsw-alias-button-info-fill,#007aff)}",
581
+ ".dshLiveEqHead{display:flex;align-items:center;gap:8px;padding:10px 12px 2px}",
582
+ ".dshLiveEqPreset{flex:1;min-width:0}",
583
+ ".dshLiveEqReset{flex:none}",
584
+ ".dshLiveEqBands{display:flex;align-items:flex-end;justify-content:space-between;gap:2px;padding:10px 8px 2px}",
585
+ ".dshLiveEqBand{flex:1;display:flex;flex-direction:column;align-items:center;gap:3px;min-width:0}",
586
+ ".dshLiveEqSlider{-webkit-appearance:slider-vertical;writing-mode:vertical-lr;direction:rtl;width:20px;height:112px;margin:0;accent-color:var(--dsw-alias-button-info-fill,#007aff)}",
587
+ ".dshLiveEqFreq{font-size:10px;line-height:1;color:var(--dsw-alias-label-tertiary,#8e8e93)}",
588
+ ".dshLiveEqDb{font-size:10px;line-height:1;font-variant-numeric:tabular-nums;min-height:12px;color:var(--dsw-alias-label-secondary,#6c6c70)}",
589
+ ".dshLiveEqHint{padding:6px 12px 10px;font-size:11px;line-height:1.5;color:var(--dsw-alias-label-tertiary,#8e8e93)}",
512
590
  ".dshLiveValue{flex:none;min-width:52px;text-align:right;font-size:12px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary,#3c3c43)}",
591
+ ".dshLiveEngineOption{min-height:72px;justify-content:center}",
592
+ ".dshLiveVoicePick{display:flex;flex:none;align-items:center;gap:8px}",
593
+ ".dshLiveVoicePick .dshLiveSelect{min-width:120px;max-width:186px}",
594
+ ".dshLiveVoiceRefresh{padding:0 10px}",
513
595
  ".dshLiveTestBtn{flex:none;height:30px;padding:0 14px;border:none;border-radius:8px;font:inherit;font-size:12.5px;font-weight:400;cursor:pointer;color:#fff;background:var(--dsw-alias-button-info-fill,#007aff)}",
514
596
  ".dshLiveTestBtn:hover{filter:brightness(1.06)}",
515
597
  ].join("");
@@ -804,6 +886,89 @@
804
886
  }
805
887
  }
806
888
 
889
+ // ── 均衡器:播放端的 10 段 BiquadFilter 链 ──────────────────
890
+ // 接在 <audio> 元素之后、扬声器之前,不碰合成也不碰下载,
891
+ // 所以播放中拖动滑块就能立刻听到变化,不用重新合成。
892
+ var EQ_BANDS = [31, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
893
+ var EQ_BAND_LABELS = ["31", "63", "125", "250", "500", "1k", "2k", "4k", "8k", "16k"];
894
+ // 预设:数值是每段增益(dB)。都只动频率响应,不动语速和停顿。
895
+ var EQ_PRESETS = [
896
+ { id: "flat", label: "settings.eq.flat", eq: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] },
897
+ // 清晰:削掉 250 那段的闷,抬 1k~4k 的辅音
898
+ { id: "clarity", label: "settings.eq.clarity", eq: [0, 1, 0, -2, 1, 3, 3, 2, 0, -1] },
899
+ // 温柔:低频补厚度,2k~4k 压掉刺耳,高频去数码毛刺
900
+ { id: "soft", label: "settings.eq.soft", eq: [-2, -1, 2, 2, 0, -1, -3, -4, -3, -4] },
901
+ // 浑厚:只补低频
902
+ { id: "deep", label: "settings.eq.deep", eq: [3, 3, 2, 1, 0, 0, 0, 0, 0, 0] },
903
+ ];
904
+ var eqCtx = null;
905
+ var eqNodes = null;
906
+
907
+ function ensureEqChain() {
908
+ if (eqNodes !== null) return eqCtx;
909
+ var Ctx = window.AudioContext || window.webkitAudioContext;
910
+ if (typeof Ctx !== "function") return null;
911
+ eqCtx = new Ctx();
912
+ eqNodes = EQ_BANDS.map(function (freq, i) {
913
+ var node = eqCtx.createBiquadFilter();
914
+ if (i === 0) node.type = "lowshelf";
915
+ else if (i === EQ_BANDS.length - 1) node.type = "highshelf";
916
+ else {
917
+ node.type = "peaking";
918
+ node.Q.value = 1;
919
+ }
920
+ node.frequency.value = freq;
921
+ node.gain.value = 0;
922
+ return node;
923
+ });
924
+ for (var j = 1; j < eqNodes.length; j += 1) eqNodes[j - 1].connect(eqNodes[j]);
925
+ // destination 只在这里接一次:每次播放都重复 connect 会让音量叠加
926
+ eqNodes[eqNodes.length - 1].connect(eqCtx.destination);
927
+ return eqCtx;
928
+ }
929
+
930
+ function applyEq(values) {
931
+ if (eqNodes === null) return;
932
+ var arr = Array.isArray(values) ? values : [];
933
+ for (var i = 0; i < eqNodes.length; i += 1) {
934
+ var g = Number(arr[i]);
935
+ eqNodes[i].gain.value = Number.isFinite(g) ? Math.max(-12, Math.min(12, g)) : 0;
936
+ }
937
+ }
938
+
939
+ /**
940
+ * 把一段音频接到均衡器上。
941
+ * 拿不到 running 的 AudioContext 就直接放弃、走原生播放 ——
942
+ * 均衡器可以有,但不能因为它把声音弄没了。
943
+ */
944
+ function attachEq(audio) {
945
+ var ctx = ensureEqChain();
946
+ if (ctx === null) return null;
947
+ var src = null;
948
+ try {
949
+ var st = String(ctx.state || "");
950
+ if (st !== "running") {
951
+ try { if (typeof ctx.resume === "function") ctx.resume(); } catch {}
952
+ st = String(ctx.state || "");
953
+ }
954
+ if (st !== "running") {
955
+ post("/debug", { what: "eq-bypass", state: st });
956
+ return null;
957
+ }
958
+ src = ctx.createMediaElementSource(audio);
959
+ src.connect(eqNodes[0]);
960
+ applyEq((manager.settings || {}).eq);
961
+ return src;
962
+ } catch (e) {
963
+ post("/debug", { what: "eq-fail", err: String((e && e.message) || e) });
964
+ // 元素已经被接管但链没接上 = 彻底没声,这里直连扬声器保底
965
+ if (src !== null) {
966
+ try { src.connect(ctx.destination); } catch {}
967
+ }
968
+ return null;
969
+ }
970
+ }
971
+
807
972
  /** 真正开始下一段。 */
808
973
  function playItem(item) {
809
974
  releaseAudio();
@@ -842,6 +1007,8 @@
842
1007
  manager.audio = audio;
843
1008
  audio.volume = effectiveVolume();
844
1009
  audio.preload = "auto";
1010
+ // 均衡器接不上(极少数环境)就保持原声,不影响播报
1011
+ manager.audioEq = attachEq(audio);
845
1012
 
846
1013
  // 分母优先用后端给的时长:Edge TTS 的 mp3 在播放前 audio.duration 常是 Infinity,
847
1014
  // 只能等播放一会儿才变成真值,中间那几秒没有后端时长的话进度条就是空的。
@@ -1138,6 +1305,7 @@
1138
1305
  if (data.settings) {
1139
1306
  var oldVolume = manager.settings.playbackVolume;
1140
1307
  manager.settings = data.settings;
1308
+ applyEq(data.settings.eq);
1141
1309
  if (manager.audio !== null && oldVolume !== data.settings.playbackVolume) {
1142
1310
  manager.audio.volume = effectiveVolume();
1143
1311
  }
@@ -2401,23 +2569,63 @@
2401
2569
  // 播放条位置只保留两种形态:顶栏(嵌进标题栏 actions)/ 左侧导航栏(占有导航行)
2402
2570
  var dock = settings.dock === "top" ? "top" : "sidebar";
2403
2571
  var voices = manager.voices || [];
2572
+ // 本机声线清单由宿主探测后下发(三端统一结构:name / id / lang / engine)
2573
+ var localVoices = Array.isArray(settings.localVoices) ? settings.localVoices : [];
2404
2574
  var rateValue = parseInt(String(settings.rate || "+0%").replace(/[^0-9-]/gu, ""), 10) || 0;
2405
2575
  var volumePercent = Math.round((settings.playbackVolume || 0.9) * 100);
2576
+ // 均衡器当前值:宿主没给或不合法时按全 0(等于不做任何增益)
2577
+ var eqValues = (Array.isArray(settings.eq) && settings.eq.length === EQ_BANDS.length)
2578
+ ? settings.eq.map(function (v) { var n = Number(v); return Number.isFinite(n) ? n : 0; })
2579
+ : EQ_BANDS.map(function () { return 0; });
2580
+ // 从数值反推当前是哪个预设,对不上任何一个就是「自定义」
2581
+ var eqPresetName = (function () {
2582
+ for (var pi = 0; pi < EQ_PRESETS.length; pi += 1) {
2583
+ var preset = EQ_PRESETS[pi];
2584
+ var same = true;
2585
+ for (var pj = 0; pj < eqValues.length; pj += 1) {
2586
+ if (Number(preset.eq[pj]) !== Number(eqValues[pj])) { same = false; break; }
2587
+ }
2588
+ if (same) return preset.id;
2589
+ }
2590
+ return "custom";
2591
+ })();
2406
2592
 
2407
2593
  function save(patch) {
2408
2594
  // 切换要"立刻"看得见:先本地合并并广播一次重渲染,再交给宿主落盘。
2409
2595
  // 只 POST 不说话的话,得等下一次 600ms 轮询,看起来像"点了不生效"。
2410
2596
  manager.settings = Object.assign({}, manager.settings || {}, patch);
2597
+ if (patch.eq !== undefined) applyEq(patch.eq);
2411
2598
  emit();
2412
2599
  if (patch.dock !== undefined) post("/debug", { what: "dock-set", dock: String(patch.dock) });
2413
2600
  post("/settings", patch);
2414
2601
  }
2415
2602
 
2603
+ function setEqValues(next) {
2604
+ save({ eq: next.slice() });
2605
+ }
2606
+
2607
+ function setEqPreset(name) {
2608
+ var preset = null;
2609
+ for (var ei = 0; ei < EQ_PRESETS.length; ei += 1) {
2610
+ if (EQ_PRESETS[ei].id === name) { preset = EQ_PRESETS[ei]; break; }
2611
+ }
2612
+ if (preset === null) return;
2613
+ save({ eq: preset.eq.slice() });
2614
+ }
2615
+
2416
2616
  var dockOptions = [
2417
2617
  ["top", t("settings.dock.top"), t("settings.dock.top.hint")],
2418
2618
  ["sidebar", t("settings.dock.sidebar"), t("settings.dock.sidebar.hint")]
2419
2619
  ];
2420
2620
 
2621
+ // 播报引擎二选一:线上(Edge 神经声线,失败自动切本地)/ 本地(系统自带声线,零网络零安装)。
2622
+ // 数据层仍保留 auto 这一档,做老配置兼容:除 local 之外一律按「线上」显示。
2623
+ var engineMode = settings.ttsEngine === "local" ? "local" : "online";
2624
+ var engineOptions = [
2625
+ ["online", t("settings.engine.online"), t("settings.engine.online.hint")],
2626
+ ["local", t("settings.engine.local"), t("settings.engine.local.hint")]
2627
+ ];
2628
+
2421
2629
  return h("div", { className: "dshLiveSettings" },
2422
2630
  h("div", { className: "dshLiveSettingsGroup" },
2423
2631
  h("div", { className: "dshLiveSettingsGroupTitle" }, t("settings.group.behavior")),
@@ -2502,23 +2710,87 @@
2502
2710
  )
2503
2711
  ),
2504
2712
 
2713
+ h("div", { className: "dshLiveSettingsGroup" },
2714
+ h("div", { className: "dshLiveSettingsGroupTitle" }, t("settings.group.engine")),
2715
+ h("div", { className: "dshLiveSettingsCard" },
2716
+ h("div", { className: "dshLiveDockPicker" },
2717
+ engineOptions.map(function (option) {
2718
+ var pick = fire(function () {
2719
+ // 「线上」就是纯线上:只用神经声线,绝不悄悄换成系统声线
2720
+ save({ ttsEngine: option[0] === "local" ? "local" : "edge" });
2721
+ });
2722
+ return h("button", {
2723
+ type: "button",
2724
+ key: option[0],
2725
+ className: "dshLiveDockOption dshLiveEngineOption",
2726
+ "data-active": engineMode === option[0] ? "true" : "false",
2727
+ "aria-pressed": engineMode === option[0] ? "true" : "false",
2728
+ onPointerDown: pick,
2729
+ onClick: pick
2730
+ },
2731
+ h("span", { className: "dshLiveDockOptionName" }, option[1]),
2732
+ h("span", { className: "dshLiveDockOptionHint" }, option[2])
2733
+ );
2734
+ })
2735
+ )
2736
+ )
2737
+ ),
2738
+
2505
2739
  h("div", { className: "dshLiveSettingsGroup" },
2506
2740
  h("div", { className: "dshLiveSettingsGroupTitle" }, t("settings.group.voice")),
2507
2741
  h("div", { className: "dshLiveSettingsCard" },
2508
- h(SettingsRow, { title: t("settings.voice"), hint: t("settings.voice.hint") },
2509
- h("select", {
2510
- className: "dshLiveSelect",
2511
- value: settings.voice || "",
2512
- onChange: function (event) {
2513
- save({ voice: event.target.value });
2514
- }
2515
- },
2516
- voices.map(function (voice) {
2517
- // 宿主只给中文标签;非中文界面用插件自己带的英文名(声线本身都是英文 id)
2518
- var name = UI_LANG === "zh" || !voice.labelEn ? voice.label : voice.labelEn;
2519
- return h("option", { key: voice.id, value: voice.id }, name);
2520
- })
2521
- )
2742
+ // 本地引擎念的是系统声线:本机有几条就列几条,让用户自己挑,
2743
+ // 装了新语音包点右边「重新检测」立刻重探(宿主那边缓存 5 分钟)。
2744
+ h(SettingsRow, {
2745
+ title: t("settings.voice"),
2746
+ hint: engineMode === "local" ? t("settings.voice.localHint") : t("settings.voice.hint")
2747
+ },
2748
+ engineMode === "local"
2749
+ ? h("div", { className: "dshLiveVoicePick" },
2750
+ h("select", {
2751
+ className: "dshLiveSelect",
2752
+ value: settings.localVoice || "",
2753
+ onChange: function (event) {
2754
+ save({ localVoice: event.target.value });
2755
+ }
2756
+ },
2757
+ localVoices.length === 0
2758
+ ? h("option", { value: "" }, t("settings.voice.localNone"))
2759
+ : localVoices.map(function (item, index) {
2760
+ // 宿主给的是系统里的原始名(Microsoft Yaoyao Desktop 这种),
2761
+ // 只去掉微软前缀;「Desktop」后缀必须留着——同名的 WinRT 版和
2762
+ // SAPI 版会成对出现(Huihui / Huihui Desktop),剥掉就变成两条
2763
+ // 一模一样、用户分不清选哪条的选项
2764
+ var name = String(item.name || "").replace(/^Microsoft\s+/i, "") || String(item.name || "");
2765
+ var tag = item.lang ? " · " + item.lang : "";
2766
+ // key 用「位置+名字」:同名条目并存时 React 才不会把两条并成一条
2767
+ return h("option", { key: index + ":" + item.name, value: item.name }, name + tag);
2768
+ })
2769
+ ),
2770
+ h("button", {
2771
+ type: "button",
2772
+ className: "dshLiveTestBtn dshLiveVoiceRefresh",
2773
+ onPointerDown: fire(function () {
2774
+ post("/local-voices", {}).then(function () { poll(); }).catch(function () {});
2775
+ }),
2776
+ onClick: fire(function () {
2777
+ post("/local-voices", {}).then(function () { poll(); }).catch(function () {});
2778
+ })
2779
+ }, t("settings.voice.refresh"))
2780
+ )
2781
+ : h("select", {
2782
+ className: "dshLiveSelect",
2783
+ value: settings.voice || "",
2784
+ onChange: function (event) {
2785
+ save({ voice: event.target.value });
2786
+ }
2787
+ },
2788
+ voices.map(function (voice) {
2789
+ // 宿主只给中文标签;非中文界面用插件自己带的英文名(声线本身都是英文 id)
2790
+ var name = UI_LANG === "zh" || !voice.labelEn ? voice.label : voice.labelEn;
2791
+ return h("option", { key: voice.id, value: voice.id }, name);
2792
+ })
2793
+ )
2522
2794
  ),
2523
2795
  h(SettingsRow, { title: t("settings.rate"), hint: t("settings.rate.hint") },
2524
2796
  h("input", {
@@ -2535,6 +2807,20 @@
2535
2807
  }),
2536
2808
  h("span", { className: "dshLiveValue" }, settings.rate || "+0%")
2537
2809
  ),
2810
+ h(SettingsRow, { title: t("settings.chunk"), hint: t("settings.chunk.hint") },
2811
+ h("input", {
2812
+ type: "range",
2813
+ className: "dshLiveRange",
2814
+ min: 40,
2815
+ max: 300,
2816
+ step: 20,
2817
+ value: Number(settings.targetChars) || 120,
2818
+ onChange: function (event) {
2819
+ save({ targetChars: Number(event.target.value) });
2820
+ }
2821
+ }),
2822
+ h("span", { className: "dshLiveValue" }, (Number(settings.targetChars) || 120) + " " + t("settings.chunk.unit"))
2823
+ ),
2538
2824
  h(SettingsRow, { title: t("settings.volume"), hint: t("settings.volume.hint") },
2539
2825
  h("input", {
2540
2826
  type: "range",
@@ -2562,6 +2848,56 @@
2562
2848
  }, t("settings.preview.btn"))
2563
2849
  )
2564
2850
  )
2851
+ ),
2852
+
2853
+ h("div", { className: "dshLiveSettingsGroup" },
2854
+ h("div", { className: "dshLiveSettingsGroupTitle" }, t("settings.group.eq")),
2855
+ h("div", { className: "dshLiveSettingsCard" },
2856
+ h("div", { className: "dshLiveEqHead" },
2857
+ h("select", {
2858
+ className: "dshLiveSelect dshLiveEqPreset",
2859
+ value: eqPresetName,
2860
+ onChange: function (event) {
2861
+ setEqPreset(event.target.value);
2862
+ }
2863
+ },
2864
+ eqPresetName === "custom" ? h("option", { value: "custom" }, t("settings.eq.custom")) : null,
2865
+ EQ_PRESETS.map(function (preset) {
2866
+ return h("option", { key: preset.id, value: preset.id }, t(preset.label));
2867
+ })
2868
+ ),
2869
+ h("button", {
2870
+ type: "button",
2871
+ className: "dshLiveTestBtn dshLiveEqReset",
2872
+ onPointerDown: fire(function () { setEqPreset("flat"); }),
2873
+ onClick: fire(function () { setEqPreset("flat"); })
2874
+ }, t("settings.eq.reset"))
2875
+ ),
2876
+ h("div", { className: "dshLiveEqBands" },
2877
+ EQ_BANDS.map(function (freq, band) {
2878
+ var value = Number(eqValues[band]) || 0;
2879
+ return h("div", { className: "dshLiveEqBand", key: freq },
2880
+ h("input", {
2881
+ type: "range",
2882
+ className: "dshLiveEqSlider",
2883
+ min: -12,
2884
+ max: 12,
2885
+ step: 1,
2886
+ value: value,
2887
+ "aria-label": EQ_BAND_LABELS[band] + "Hz",
2888
+ onChange: function (event) {
2889
+ var next = eqValues.slice();
2890
+ next[band] = Number(event.target.value);
2891
+ setEqValues(next);
2892
+ }
2893
+ }),
2894
+ h("span", { className: "dshLiveEqFreq" }, EQ_BAND_LABELS[band]),
2895
+ h("span", { className: "dshLiveEqDb" }, (value > 0 ? "+" : "") + value)
2896
+ );
2897
+ })
2898
+ ),
2899
+ h("div", { className: "dshLiveEqHint" }, t("settings.eq.hint"))
2900
+ )
2565
2901
  )
2566
2902
  );
2567
2903
  }
@@ -2635,9 +2971,10 @@
2635
2971
  var guard = 0;
2636
2972
  while (stream.buffer.length > 0 && guard < 40) {
2637
2973
  guard += 1;
2638
- // 首句门槛低一些,尽快有人声;之后每段攒到 36 字左右,
2639
- // 让合成速度跟得上播放,不至于一句一卡。
2640
- var need = stream.firstDone ? 36 : 14;
2974
+ // 首句门槛 140 字:这一段能播 26 秒左右,要覆盖的是「模型把整篇吐完的时间
2975
+ // + 主文首段在 Edge 排队里的合成时间」,实测这两项加起来 20~30 秒,80 字
2976
+ // (16 秒)顶不住,开头会空十几秒;之后再按「段落字数」这个设置值切。
2977
+ var need = stream.firstDone ? Math.max(40, Number(settings.targetChars) || 120) : 140;
2641
2978
  var cut = 0;
2642
2979
  var i = 0;
2643
2980
  for (i = 0; i < stream.buffer.length; i += 1) {
@@ -2647,13 +2984,14 @@
2647
2984
  }
2648
2985
  }
2649
2986
  if (cut === 0) {
2650
- // 一直没到门槛就攒着;攒到 90 字说明没遇到句号,只能在逗号处硬切
2651
- if (stream.buffer.length < 90) break;
2987
+ // 没遇到够长的句号就继续攒;攒够 need 字还全是逗号,就在最后一个逗号处硬切。
2988
+ // 这里的上限必须跟着 need 走——写死 90 会在门槛 140 时提前软切,门槛形同虚设。
2989
+ if (stream.buffer.length < need) break;
2652
2990
  var soft = 0;
2653
2991
  for (i = 0; i < stream.buffer.length; i += 1) {
2654
2992
  if (RE_SOFT.test(stream.buffer.charAt(i))) soft = i + 1;
2655
2993
  }
2656
- cut = soft > 20 ? soft : 90;
2994
+ cut = soft > 20 ? soft : need;
2657
2995
  }
2658
2996
  var head = stream.buffer.slice(0, cut);
2659
2997
  stream.buffer = stream.buffer.slice(cut);
@@ -2672,6 +3010,8 @@
2672
3010
  var data = (event && event.data) || {};
2673
3011
  var chunk = data.chunk;
2674
3012
  if (!chunk) return;
3013
+ // 逐条增量打点已删除:一篇长文能写出九百多行、把日志迅速撑到轮转,
3014
+ // 反而挤掉了真正要查的 enqueue / synth / play 记录。排查看 block-type。
2675
3015
  manager.streamingAt = Date.now();
2676
3016
  if (manager.streaming !== true) {
2677
3017
  manager.streaming = true;