dsh-plugin-lookatstudy 0.12.5 → 0.13.0

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/lib/client.js CHANGED
@@ -249,6 +249,12 @@ window.__ModuleLoader__.load({
249
249
  /* inline error text (write-action failures) */
250
250
  .lks-propcard-err{color:var(--dsw-alias-state-error-primary);font-size:12px;margin-top:6px;flex:none}
251
251
 
252
+ /* read-aloud bar (teach pane, under the view tabs) */
253
+ .lks-readbar{display:flex;align-items:center;gap:6px;margin:0 0 8px;min-height:26px}
254
+ .lks-readbar .lks-btn{display:inline-flex;align-items:center;gap:4px}
255
+ .lks-readbar-cur{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px;color:var(--dsw-alias-label-secondary);border-left:2px solid var(--dsw-alias-state-info-primary,var(--dsw-alias-business-primary));padding-left:8px}
256
+ .lks-readbar-notice{flex:none;font-size:11.5px;color:var(--dsw-alias-label-tertiary)}
257
+
252
258
  /* settings section (settings.section entry inside the host settings shell) */
253
259
  .lks-settings{display:flex;flex-direction:column;gap:18px;font-family:var(--dsw-font-family);color:var(--dsw-alias-label-primary)}
254
260
  .lks-set-row h3{margin:0 0 4px;font-size:15px;font-weight:600}
@@ -730,6 +736,23 @@ window.__ModuleLoader__.load({
730
736
  });
731
737
  this.refresh();
732
738
  }
739
+ /** Synthesize one speakable chunk to MP3 (host-side Edge TTS, cache-first). */
740
+ async tts(text, voice) {
741
+ const body = await fetchJson("/lookatstudy/api/tts", {
742
+ method: "POST",
743
+ headers: { "content-type": "application/json" },
744
+ body: JSON.stringify({
745
+ text,
746
+ voice
747
+ })
748
+ });
749
+ const raw = body.dataBase64;
750
+ if (body.ok !== true || typeof raw !== "string") throw new Error("tts response missing audio payload");
751
+ const bin = atob(raw);
752
+ const bytes = new Uint8Array(bin.length);
753
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
754
+ return bytes.buffer;
755
+ }
733
756
  };
734
757
  /** The one shared store instance backing every study seat component. */
735
758
  const studyStore = new StudyStore();
@@ -1585,6 +1608,12 @@ window.__ModuleLoader__.load({
1585
1608
  "rail.delete.title.confirm": "再点一次确认删除(含全部进度与笔记)。反悔前可先在设置页备份状态文件",
1586
1609
  "note.delete": "删除本条笔记",
1587
1610
  "note.delete.confirm": "确认删除?",
1611
+ "read.play": "朗读本课",
1612
+ "read.stop": "停止朗读",
1613
+ "read.pause": "暂停朗读",
1614
+ "read.resume": "继续朗读",
1615
+ "read.engine.system": "网络合成不可用,已切换为系统语音",
1616
+ "read.unavailable": "朗读不可用",
1588
1617
  "rail.section.collapse": "折叠本章节",
1589
1618
  "rail.section.expand": "展开本章节({count} 课时)",
1590
1619
  "rail.section.count": "{count} 课",
@@ -1707,6 +1736,12 @@ window.__ModuleLoader__.load({
1707
1736
  "rail.delete.title.confirm": "Click again to confirm (all progress and notes included). Back up via the state file in Settings first",
1708
1737
  "note.delete": "Delete this note",
1709
1738
  "note.delete.confirm": "Delete?",
1739
+ "read.play": "Read aloud",
1740
+ "read.stop": "Stop reading",
1741
+ "read.pause": "Pause reading",
1742
+ "read.resume": "Resume reading",
1743
+ "read.engine.system": "Network synthesis unavailable — switched to the system voice",
1744
+ "read.unavailable": "Read-aloud unavailable",
1710
1745
  "rail.section.collapse": "Collapse this section",
1711
1746
  "rail.section.expand": "Expand this section ({count} lessons)",
1712
1747
  "rail.section.count": "{count} lessons",
@@ -1925,6 +1960,433 @@ window.__ModuleLoader__.load({
1925
1960
  container.append(legend);
1926
1961
  }
1927
1962
  //#endregion
1963
+ //#region src/vendor/speech-text.ts
1964
+ /**
1965
+ * Vendored from LookatStudy shared/speech-text.ts (MIT License,
1966
+ * https://github.com/Kaiji-Z/LookatStudy), upstream v0.28.0 — includes the
1967
+ * v11.5 whole-display-group avoidance fix era. Unmodified except this
1968
+ * provenance header.
1969
+ *
1970
+ * speech-text —— TTS 朗读文本处理(纯函数,渲染层/主进程共用)。
1971
+ *
1972
+ * normalizeSpeechText: markdown → 可朗读纯文本。代码不读(围栏/行内整体移除),
1973
+ * 链接留文字,强调/标题/列表/引用/表格标记剥离 —— 导师"念的是话,不是版面"。
1974
+ *
1975
+ * splitSentences: 流式友好的切句。每次喂"累积文本"返回完整句 + 余量(幂等,StrictMode 安全);
1976
+ * flush=true 时尾句强制吐出。终止标点:中英句号/叹/问/分号 + 省略号;ASCII '.' 要求后跟
1977
+ * 空白且前字符非数字(3.14 不切)。超过 maxBuffer 仍无终止标点 → 在最后的软标点(、,;: 空白)
1978
+ * 处断开,再无则硬断 —— 保证"导师边生成边念"的流式管线永远不会饿死。
1979
+ *
1980
+ * v11.2 表意分隔:换行与 emoji 也是句界。无标点的段落/列表/短句流文本,
1981
+ * 旧逻辑的强制断句块会在显示层并组吞整段(高亮整段到结尾);现在逐行/逐 emoji
1982
+ * 成句,显示组另加长度上限兜底 —— karaoke 高亮粒度永远可控。
1983
+ */
1984
+ /** 终止标点(出现即成句,连续终止/右引号并吞进句尾) */
1985
+ const HARD = /* @__PURE__ */ new Set([
1986
+ "。",
1987
+ "!",
1988
+ "?",
1989
+ "!",
1990
+ "?",
1991
+ ";",
1992
+ ";",
1993
+ "…"
1994
+ ]);
1995
+ /** 软标点(超长兜底断句位) */
1996
+ const SOFT = /* @__PURE__ */ new Set([
1997
+ ",",
1998
+ ",",
1999
+ "、",
2000
+ ":",
2001
+ ":",
2002
+ " ",
2003
+ " "
2004
+ ]);
2005
+ /** v11.2 emoji 基字符范围(表意/象形/杂项符号/箭头符号区);修饰符(VS16/ZWJ/肤色)不单独成界 */
2006
+ const EMOJI_CP_RE = /[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}]/u;
2007
+ function isEmojiCp(cp) {
2008
+ return cp != null && EMOJI_CP_RE.test(String.fromCodePoint(cp));
2009
+ }
2010
+ /** 句尾可并吞的右闭合符 */
2011
+ const CLOSERS = /* @__PURE__ */ new Set([
2012
+ "”",
2013
+ "\"",
2014
+ "」",
2015
+ "』",
2016
+ "》",
2017
+ ")",
2018
+ "】",
2019
+ "])".slice(0, 1)
2020
+ ]);
2021
+ function normalizeSpeechText(md) {
2022
+ let s = md;
2023
+ s = s.replace(/\r\n?/g, "\n");
2024
+ s = s.replace(/(?:^|\n)[ \t]*(?:```|~~~)[^\n]*\n[\s\S]*?(?:\n[ \t]*(?:```|~~~)[^\n]*|\n?$)/g, "\n");
2025
+ s = s.replace(/`[^`\n]*`/g, "");
2026
+ s = s.replace(/!\[[^\]]*\]\([^)\n]*\)/g, "");
2027
+ s = s.replace(/\[([^\]]+)\]\([^)\n]*\)/g, "$1");
2028
+ s = s.replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, "");
2029
+ s = s.replace(/^[ \t]*(?:[-*+]|\d+\.)[ \t]+/gm, "");
2030
+ s = s.replace(/^[ \t]*>[ \t]?/gm, "");
2031
+ s = s.replace(/\|/g, " ");
2032
+ s = s.replace(/^[ \t]*[-: ]{3,}[ \t]*$/gm, "");
2033
+ s = s.replace(/(\*\*|__)(.*?)\1/g, "$2");
2034
+ s = s.replace(/(?<![*\w])(\*|_)(?!\s)(.+?)(?<!\s)\1(?![*\w])/g, "$2");
2035
+ s = s.replace(/~~(.+?)~~/g, "$1");
2036
+ s = s.replace(/\n{3,}/g, "\n\n");
2037
+ return s.trim();
2038
+ }
2039
+ /**
2040
+ * v11.2 朗读句表单一入口(v11.4 起仅合成侧使用):tts-service 用它切段;
2041
+ * 渲染层 karaoke **不再调用**(改吃 ttsAudio.sentence 权威原文,见
2042
+ * playedSentencePrefix)——句表从合成侧单向流出,显示侧零复算零分叉。
2043
+ */
2044
+ function speechSentencesOf(text) {
2045
+ return splitSentences(normalizeSpeechText(text), { flush: true }).sentences;
2046
+ }
2047
+ function splitSentences(text, opts = {}) {
2048
+ const maxBuffer = Math.max(8, opts.maxBuffer ?? 120);
2049
+ const flush = opts.flush ?? false;
2050
+ const out = [];
2051
+ const n = text.length;
2052
+ let start = 0;
2053
+ let i = 0;
2054
+ let lastSoft = -1;
2055
+ const emit = (end) => {
2056
+ const piece = text.slice(start, end).trim();
2057
+ if (piece) out.push(piece);
2058
+ start = end;
2059
+ lastSoft = -1;
2060
+ };
2061
+ while (i < n) {
2062
+ const ch = text[i];
2063
+ const cp = text.codePointAt(i);
2064
+ if (isEmojiCp(cp)) {
2065
+ let j = i + (cp > 65535 ? 2 : 1);
2066
+ while (j < n) {
2067
+ const cj = text.codePointAt(j);
2068
+ const ul = cj > 65535 ? 2 : 1;
2069
+ if (cj === 65039 || cj === 8205 || cj >= 127995 && cj <= 127999 || isEmojiCp(cj)) {
2070
+ j += ul;
2071
+ continue;
2072
+ }
2073
+ break;
2074
+ }
2075
+ emit(j);
2076
+ i = j;
2077
+ continue;
2078
+ }
2079
+ if (ch === "\n") {
2080
+ const piece = text.slice(start, i + 1).replace(/[^\S\n]+$/, "");
2081
+ if (piece.trim()) out.push(piece);
2082
+ start = i + 1;
2083
+ lastSoft = -1;
2084
+ i++;
2085
+ continue;
2086
+ }
2087
+ if (HARD.has(ch)) {
2088
+ let j = i + 1;
2089
+ while (j < n && (HARD.has(text[j]) || CLOSERS.has(text[j]))) j++;
2090
+ emit(j);
2091
+ i = j;
2092
+ continue;
2093
+ }
2094
+ if (ch === ".") {
2095
+ const prev = i > 0 ? text[i - 1] : "";
2096
+ const next = i + 1 < n ? text[i + 1] : "";
2097
+ if ((next === "" || /\s/.test(next)) && !/\d/.test(prev)) {
2098
+ let j = i + 1;
2099
+ while (j < n && (CLOSERS.has(text[j]) || HARD.has(text[j]))) j++;
2100
+ emit(j);
2101
+ i = j;
2102
+ continue;
2103
+ }
2104
+ }
2105
+ if (SOFT.has(ch)) lastSoft = i;
2106
+ if (i - start + 1 > maxBuffer) {
2107
+ if (lastSoft > start) {
2108
+ const cut = lastSoft;
2109
+ emit(cut);
2110
+ start = cut + 1;
2111
+ } else {
2112
+ emit(i + 1);
2113
+ i++;
2114
+ continue;
2115
+ }
2116
+ }
2117
+ i++;
2118
+ }
2119
+ const rest = text.slice(start).trim();
2120
+ if (flush && rest) out.push(rest);
2121
+ return {
2122
+ sentences: out,
2123
+ rest: flush ? "" : rest
2124
+ };
2125
+ }
2126
+ //#endregion
2127
+ //#region src/vendor/math-speech.ts
2128
+ /**
2129
+ * Vendored from LookatStudy shared/math-speech.ts (MIT License,
2130
+ * https://github.com/Kaiji-Z/LookatStudy), upstream v0.28.0. Unmodified
2131
+ * except this provenance header. zh-only by design upstream — the plugin's
2132
+ * read-aloud pipeline applies it for zh and strips $..$ delimiters for other
2133
+ * locales (conversion for more languages is future work).
2134
+ *
2135
+ * math-speech —— LaTeX 公式 → 中文口语(v0.19 朗读口语化,纯函数,verify 直测)。
2136
+ *
2137
+ * 只在**合成侧**应用(真正念出来的文本);karaoke 高亮/匹配层继续吃 `$..$` 原文
2138
+ * (DOM 侧 getTextModel 收 KaTeX annotation 的 TeX 源,两侧在 canonical 空间对齐)
2139
+ * ——念的是人话,亮的是原文,互不干扰。
2140
+ *
2141
+ * 规则表起步:常见记号(分式/根号/上下标/希腊字母/关系与算符)覆盖优先,
2142
+ * 未覆盖宏退化为逐字母(好于念"反斜杠 f-r-a-c")。
2143
+ */
2144
+ /** 单符号命令 → 中文(长命令先试,避免 \le 被 \leq 截断)。 */
2145
+ const SYMBOLS = [
2146
+ ["\\leq", "小于等于"],
2147
+ ["\\le", "小于等于"],
2148
+ ["\\geq", "大于等于"],
2149
+ ["\\ge", "大于等于"],
2150
+ ["\\neq", "不等于"],
2151
+ ["\\ne", "不等于"],
2152
+ ["\\approx", "约等于"],
2153
+ ["\\equiv", "恒等于"],
2154
+ ["\\pm", "正负"],
2155
+ ["\\times", "乘以"],
2156
+ ["\\cdot", "点乘"],
2157
+ ["\\div", "除以"],
2158
+ ["\\to", "趋于"],
2159
+ ["\\rightarrow", "趋于"],
2160
+ ["\\Rightarrow", "推出"],
2161
+ ["\\infty", "无穷"],
2162
+ ["\\sum", "求和"],
2163
+ ["\\prod", "连乘"],
2164
+ ["\\int", "积分"],
2165
+ ["\\lim", "极限"],
2166
+ ["\\log", "对数"],
2167
+ ["\\ln", "自然对数"],
2168
+ ["\\exp", "指数"],
2169
+ ["\\in", "属于"],
2170
+ ["\\subset", "包含于"],
2171
+ ["\\cup", "并"],
2172
+ ["\\cap", "交"],
2173
+ ["\\forall", "任意"],
2174
+ ["\\exists", "存在"],
2175
+ ["\\partial", "偏导"],
2176
+ ["\\nabla", "梯度"],
2177
+ ["\\angle", "角"],
2178
+ ["\\degree", "度"],
2179
+ ["\\cdots", "点点点"],
2180
+ ["\\ldots", "点点点"]
2181
+ ];
2182
+ const GREEK = {
2183
+ alpha: "阿尔法",
2184
+ beta: "贝塔",
2185
+ gamma: "伽马",
2186
+ delta: "德尔塔",
2187
+ epsilon: "艾普西隆",
2188
+ zeta: "泽塔",
2189
+ eta: "伊塔",
2190
+ theta: "西塔",
2191
+ iota: "约塔",
2192
+ kappa: "卡帕",
2193
+ lambda: "拉姆达",
2194
+ mu: "缪",
2195
+ nu: "纽",
2196
+ xi: "克西",
2197
+ pi: "派",
2198
+ rho: "柔",
2199
+ sigma: "西格马",
2200
+ tau: "陶",
2201
+ phi: "斐",
2202
+ chi: "凯",
2203
+ psi: "普西",
2204
+ omega: "欧米伽"
2205
+ };
2206
+ /** 读取 `{...}` 花括号组(从 openIdx 的下一个字符起,返回内容与结束下标)。 */
2207
+ function readGroup(s, i) {
2208
+ if (s[i] !== "{") return null;
2209
+ let depth = 0;
2210
+ for (let j = i; j < s.length; j++) if (s[j] === "{") depth++;
2211
+ else if (s[j] === "}") {
2212
+ depth--;
2213
+ if (depth === 0) return {
2214
+ body: s.slice(i + 1, j),
2215
+ end: j + 1
2216
+ };
2217
+ }
2218
+ return null;
2219
+ }
2220
+ /** 单 token(花括号组或单字符)读取:返回 {body, end}。 */
2221
+ function readAtom(s, i) {
2222
+ const g = readGroup(s, i);
2223
+ if (g) return g;
2224
+ return {
2225
+ body: s[i] ?? "",
2226
+ end: i + 1
2227
+ };
2228
+ }
2229
+ /** LaTeX 片段 → 中文口语(递归:命令参数体内再走一遍)。 */
2230
+ function mathToSpokenZH(tex) {
2231
+ let s = tex;
2232
+ s = s.replace(/\\(?:left|right|displaystyle|limits)\b/g, " ");
2233
+ s = s.replace(/\\[,;!\s]|\\quad|\\qquad|~/g, " ");
2234
+ for (let guard = 0; guard < 12; guard++) {
2235
+ let next = s;
2236
+ next = next.replace(/\\[dt]?frac(?![A-Za-z])/g, "\\frac");
2237
+ {
2238
+ let i;
2239
+ while ((i = next.indexOf("\\frac")) >= 0) {
2240
+ const a = readAtom(next, i + 5);
2241
+ const b = readAtom(next, a.end);
2242
+ next = next.slice(0, i) + `${a.body} 分之 ${b.body} ` + next.slice(b.end);
2243
+ }
2244
+ }
2245
+ {
2246
+ let i;
2247
+ while ((i = next.indexOf("\\binom")) >= 0) {
2248
+ const a = readAtom(next, i + 6);
2249
+ const b = readAtom(next, a.end);
2250
+ next = next.slice(0, i) + `${a.body} 取 ${b.body} ` + next.slice(b.end);
2251
+ }
2252
+ }
2253
+ next = next.replace(/\\sqrt\s*\[( {[^{}]*} |[^\s\\]+)\s*\]\s*/g, (_m, n) => `${n.trim()} 次根号 `);
2254
+ {
2255
+ let i;
2256
+ while ((i = next.indexOf("\\sqrt")) >= 0) {
2257
+ const a = readAtom(next, i + 5);
2258
+ next = next.slice(0, i) + `根号 ${a.body} ` + next.slice(a.end);
2259
+ }
2260
+ }
2261
+ next = next.replace(/\\(?:mathbf|mathbb|mathrm|mathit|boldsymbol|text|operatorname)\s*/g, (m) => m.startsWith("\\text") ? "" : " ");
2262
+ if (next === s) {
2263
+ s = next;
2264
+ break;
2265
+ }
2266
+ s = next;
2267
+ }
2268
+ for (let guard = 0; guard < 8; guard++) {
2269
+ const next = s.replace(/(\\frac|[\w)\]}]|\s)\s*\^\s*(\{[^{}]*\}|[^\s])|(\\frac|[\w)\]}]| )\s*_\s*(\{[^{}]*\}|[^\s])/g, (_m, base1, sup, base2, sub) => {
2270
+ if (sup !== void 0) return `${base1} 的 ${sup.replace(/[{}]/g, "")} 次方`;
2271
+ return `${base2} 下标 ${sub.replace(/[{}]/g, "")}`;
2272
+ });
2273
+ if (next === s) break;
2274
+ s = next;
2275
+ }
2276
+ for (const [cmd, zh] of SYMBOLS) s = s.split(cmd).join(` ${zh} `);
2277
+ s = s.replace(/\\([A-Za-z]+)\b/g, (_m, name) => ` ${GREEK[name] ?? name} `);
2278
+ s = s.replace(/[{}]/g, " ");
2279
+ return s.replace(/\s+/g, " ").trim();
2280
+ }
2281
+ /** 句子级入口:把 `$..$`/`$$..$$` 段替换为口语;段外文本逐字节不变。 */
2282
+ function speakMathInSentence(sentence) {
2283
+ return sentence.replace(/\$\$([^$]+)\$\$|\$([^$]+)\$/g, (_m, block, inline) => {
2284
+ const spoken = mathToSpokenZH(block ?? inline ?? "");
2285
+ return spoken ? ` ${spoken} ` : "";
2286
+ });
2287
+ }
2288
+ //#endregion
2289
+ //#region src/client/readaloud.ts
2290
+ const sleep = (ms) => new Promise((resolve) => {
2291
+ setTimeout(resolve, ms);
2292
+ });
2293
+ var ReadAloudController = class {
2294
+ sentences;
2295
+ primary;
2296
+ fallback;
2297
+ notify;
2298
+ index = 0;
2299
+ stopped = false;
2300
+ paused = false;
2301
+ engine = null;
2302
+ degraded = false;
2303
+ run = null;
2304
+ constructor(sentences, primary, fallback, notify) {
2305
+ this.sentences = sentences;
2306
+ this.primary = primary;
2307
+ this.fallback = fallback;
2308
+ this.notify = notify;
2309
+ }
2310
+ emit(state) {
2311
+ this.notify?.({
2312
+ index: this.index,
2313
+ total: this.sentences.length,
2314
+ engine: this.engine,
2315
+ state,
2316
+ degraded: this.degraded
2317
+ });
2318
+ }
2319
+ /** Speak the whole queue; the returned promise settles when done or stopped. */
2320
+ start() {
2321
+ if (this.run !== null) return this.run;
2322
+ this.stopped = false;
2323
+ this.run = (async () => {
2324
+ this.engine = "edge";
2325
+ this.emit("speaking");
2326
+ while (!this.stopped && this.index < this.sentences.length) {
2327
+ const text = this.sentences[this.index];
2328
+ try {
2329
+ await this.speakOn(text);
2330
+ } catch {
2331
+ if (this.stopped) return;
2332
+ if (this.engine === "edge") {
2333
+ this.degraded = true;
2334
+ this.engine = "system";
2335
+ this.emit("speaking");
2336
+ try {
2337
+ await this.speakOn(text);
2338
+ } catch {
2339
+ this.emit("idle");
2340
+ return;
2341
+ }
2342
+ } else {
2343
+ this.emit("idle");
2344
+ return;
2345
+ }
2346
+ }
2347
+ if (this.stopped) return;
2348
+ this.index += 1;
2349
+ this.emit("speaking");
2350
+ }
2351
+ this.emit("idle");
2352
+ })();
2353
+ return this.run;
2354
+ }
2355
+ async speakOn(text) {
2356
+ await (this.engine === "system" ? this.fallback : this.primary).speak(text);
2357
+ while (this.paused && !this.stopped) await sleep(120);
2358
+ }
2359
+ pause() {
2360
+ if (this.stopped || this.paused) return;
2361
+ this.paused = true;
2362
+ (this.engine === "system" ? this.fallback : this.primary).pause();
2363
+ this.emit("paused");
2364
+ }
2365
+ resume() {
2366
+ if (!this.paused) return;
2367
+ this.paused = false;
2368
+ (this.engine === "system" ? this.fallback : this.primary).resume();
2369
+ this.emit("speaking");
2370
+ }
2371
+ /** Stop everything; the controller is single-use — build a fresh one to replay. */
2372
+ stop() {
2373
+ this.stopped = true;
2374
+ this.paused = false;
2375
+ this.primary.cancel();
2376
+ this.fallback.cancel();
2377
+ this.emit("idle");
2378
+ }
2379
+ get status() {
2380
+ return {
2381
+ index: this.index,
2382
+ total: this.sentences.length,
2383
+ engine: this.engine,
2384
+ state: this.stopped ? "idle" : this.paused ? "paused" : "speaking",
2385
+ degraded: this.degraded
2386
+ };
2387
+ }
2388
+ };
2389
+ //#endregion
1928
2390
  //#region src/client/views.tsx
1929
2391
  /**
1930
2392
  * The study tab: one `conversation.view` entry rendering the whole plugin —
@@ -2623,11 +3085,85 @@ window.__ModuleLoader__.load({
2623
3085
  }
2624
3086
  }, s.label))) : null, (0, react.createElement)(ActionError, { error }));
2625
3087
  }
3088
+ /** Browser speechSynthesis engine — the offline/endpoint-gone fallback voice. */
3089
+ function systemSpeechEngine() {
3090
+ return {
3091
+ speak(text) {
3092
+ return new Promise((resolve, reject) => {
3093
+ const synth = typeof window === "undefined" ? void 0 : window.speechSynthesis;
3094
+ if (synth === void 0) {
3095
+ reject(/* @__PURE__ */ new Error("no speechSynthesis"));
3096
+ return;
3097
+ }
3098
+ const utterance = new SpeechSynthesisUtterance(text);
3099
+ utterance.lang = "zh-CN";
3100
+ utterance.onend = () => resolve();
3101
+ utterance.onerror = () => reject(/* @__PURE__ */ new Error("speechSynthesis failed"));
3102
+ synth.speak(utterance);
3103
+ });
3104
+ },
3105
+ pause() {
3106
+ window.speechSynthesis?.pause();
3107
+ },
3108
+ resume() {
3109
+ window.speechSynthesis?.resume();
3110
+ },
3111
+ cancel() {
3112
+ window.speechSynthesis?.cancel();
3113
+ }
3114
+ };
3115
+ }
3116
+ /** Edge-over-dashboard engine: MP3 from /api/tts played through an <audio>. */
3117
+ function audioSpeechEngine(fetchTts) {
3118
+ let audio = null;
3119
+ return {
3120
+ speak(text) {
3121
+ return fetchTts(text).then((buf) => new Promise((resolve, reject) => {
3122
+ audio = new Audio(URL.createObjectURL(new Blob([buf], { type: "audio/mpeg" })));
3123
+ audio.onended = () => resolve();
3124
+ audio.onerror = () => reject(/* @__PURE__ */ new Error("audio playback failed"));
3125
+ audio.play().catch(reject);
3126
+ }));
3127
+ },
3128
+ pause() {
3129
+ audio?.pause();
3130
+ },
3131
+ resume() {
3132
+ audio?.play();
3133
+ },
3134
+ cancel() {
3135
+ audio?.pause();
3136
+ audio = null;
3137
+ }
3138
+ };
3139
+ }
2626
3140
  /** Right column: the blackboard — focus-lesson 讲解/脑图/概念图 plus the Cornell 笔记. */
2627
3141
  function BlackboardColumn({ data, deleteNote }) {
2628
3142
  const lesson = data?.lesson ?? null;
3143
+ const { tts } = useStudy();
2629
3144
  const [pane, setPane] = (0, react.useState)("teach");
2630
3145
  const [error, setError] = (0, react.useState)(null);
3146
+ const [read, setRead] = (0, react.useState)(null);
3147
+ const readCtl = (0, react.useRef)(null);
3148
+ const [readError, setReadError] = (0, react.useState)(null);
3149
+ const startReading = () => {
3150
+ if (lesson === null || lesson.speechText.trim() === "") return;
3151
+ readCtl.current?.stop();
3152
+ const controller = new ReadAloudController(speechSentencesOf(lesson.speechText).map((s) => speakMathInSentence(s)), audioSpeechEngine((text) => tts(text)), systemSpeechEngine(), (s) => {
3153
+ setRead(s);
3154
+ if (s.degraded && s.engine === "system" && !readError) setReadError(tr("read.engine.system"));
3155
+ });
3156
+ readCtl.current = controller;
3157
+ setReadError(null);
3158
+ controller.start().catch((err) => {
3159
+ setReadError(err instanceof Error ? err.message : String(err));
3160
+ });
3161
+ };
3162
+ const stopReading = () => {
3163
+ readCtl.current?.stop();
3164
+ setRead(null);
3165
+ setReadError(null);
3166
+ };
2631
3167
  const [armedNote, setArmedNote] = (0, react.useState)(null);
2632
3168
  (0, react.useEffect)(() => {
2633
3169
  if (armedNote === null) return;
@@ -2690,7 +3226,35 @@ window.__ModuleLoader__.load({
2690
3226
  onClick: () => {
2691
3227
  setPane("cmap");
2692
3228
  }
2693
- }, (0, react.createElement)(IconGlobeOutline14, { size: 13 }), tr("viewtab.cmap"))), pane === "teach" ? (0, react.createElement)("div", {
3229
+ }, (0, react.createElement)(IconGlobeOutline14, { size: 13 }), tr("viewtab.cmap"))), pane === "teach" ? (0, react.createElement)("div", { className: "lks-readbar" }, (0, react.createElement)("button", {
3230
+ className: "lks-btn ghost",
3231
+ style: {
3232
+ padding: "3px 8px",
3233
+ fontSize: "12.5px",
3234
+ flex: "none"
3235
+ },
3236
+ title: read !== null && read.state === "speaking" ? tr("read.pause") : tr("read.play"),
3237
+ onClick: () => {
3238
+ if (read !== null && read.state === "speaking") {
3239
+ readCtl.current?.pause();
3240
+ return;
3241
+ }
3242
+ if (read !== null && read.state === "paused") {
3243
+ readCtl.current?.resume();
3244
+ return;
3245
+ }
3246
+ startReading();
3247
+ }
3248
+ }, (0, react.createElement)(IconPlayOutline16, { size: 12 }), read !== null && read.state === "speaking" ? tr("read.pause") : read !== null && read.state === "paused" ? tr("read.resume") : tr("read.play")), read !== null ? (0, react.createElement)("button", {
3249
+ className: "lks-btn ghost",
3250
+ style: {
3251
+ padding: "3px 8px",
3252
+ fontSize: "12.5px",
3253
+ flex: "none"
3254
+ },
3255
+ title: tr("read.stop"),
3256
+ onClick: stopReading
3257
+ }, tr("read.stop")) : null, read !== null && read.state !== "idle" && lesson !== null ? (0, react.createElement)("span", { className: "lks-readbar-cur" }, (speechSentencesOf(lesson.speechText)[read.index] ?? "").slice(0, 80)) : null, readError !== null ? (0, react.createElement)("span", { className: "lks-readbar-notice" }, readError) : null) : null, pane === "teach" ? (0, react.createElement)("div", {
2694
3258
  className: "lks-prose",
2695
3259
  ref: proseRef,
2696
3260
  dangerouslySetInnerHTML: { __html: lesson.html }