From b9d4077883dec8b0c588af5c84bc5de334d0ff6d Mon Sep 17 00:00:00 2001 From: lexicon Date: Fri, 21 Aug 2026 20:06:01 +0800 Subject: [PATCH] =?utf8?q?V1:=20TTS=20=E5=88=87=E6=8D=A2=20Edge=20?= =?utf8?q?=E7=A5=9E=E7=BB=8F=E5=97=93=E9=9F=B3(Sonia)+=E7=BC=A9=E5=86=99?= =?utf8?q?=E5=B1=95=E5=BC=80;=20=E4=BF=AE=E5=A4=8D=20off=5Fed=20=E5=90=8C?= =?utf8?q?=E6=AD=A5=E8=AF=AF=E5=88=A0=E8=BF=9B=E5=BA=A6;=20=E8=A1=A5?= =?utf8?q?=E5=9B=9E=208/20=20=E4=B8=A2=E5=A4=B1=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit - backend/main.py: /api/tts 引擎由有道/百度改为 Edge 神经嗓音(en-GB-SoniaNeural)首选, 有道/百度作兜底; 合成前做无歧义缩写展开(sth->something 等); 缓存键加 edge_sonia_ 前缀 - frontend/public/sw.js: CACHE_VERSION v1->v2, 强制重新拉取 TTS 音频避免旧难听嗓音残留 - frontend/src/sync.js: 删除 phase 2b 破坏性删除逻辑, 同步改为只补不删(布尔并集), 修复 Review2 等已完成进度被静默丢弃的问题 - backend/db/bcd.db: 修正 8/20 云端记录(G54 review3 补回; G56 review2/G57 review1 撤销) --- .gitignore | 4 ++ backend/db/bcd.db | Bin 9469952 -> 9469952 bytes backend/main.py | 89 ++++++++++++++++++++++++++++++------------ frontend/public/sw.js | 2 +- frontend/src/sync.js | 19 ++++----- 5 files changed, 75 insertions(+), 39 deletions(-) diff --git a/.gitignore b/.gitignore index aabb8b3..8b60d53 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,7 @@ backend/db/tts_cache/ # certbot 一次性 DNS 挑战值(临时文件,不入库) deploy/dns-txt-value.txt + +# 导出/打包产物(供下载,不入库) +words_package.zip +words_export.xlsx diff --git a/backend/db/bcd.db b/backend/db/bcd.db index b54b8286f85051d8d12876342b54fe288ca41899..ed30f862501ab5658f92a4dc364865ac4f82eaf1 100644 GIT binary patch delta 1205 zcmZwESx-|z7{>9=w6%pERFqO^0b6ka6)i2478gVjaRosT6}1Sc;8vmHihxydp?C-v z$3QSdKY>eZG+ueD7lsSHGbS1?NKE_){;`o1^(4QX%sWrsyff3+_s-A<)gf|+v$l%d zXTjUmVj?nCJup=*Afbl=M$Cf=W+WjQ7AQ!;d@R61q+$^kBMnxh!-foG!VU+rkc}KH zffFt)MJ|?MIaXjL@{o^JSdBF(z*?+>8-?(|iy{=mhZ6V^Kq=N^12$q4%CH&b*n+L7 zz&2E3JF2h)JFyG9u?KrGb9Xh^hg#HOKkCtd18BrS9KvBV;Ruf67=mcVakQWnZ8(9G zIE8j};55$QEY9IPLb!lVbRmq3=*A^n#uZ$}HC#szZlD)^=*LY&a0|C_2Lrf^d$^AW z7>rF-M{}HFBxVdnQ-xiLh}YtYc``*O8j_sKl=+SMsq#`8SH@%R3sFn5LHQu6UX(j4 z#GXu}aJZzev#qx? zoqf1Gy(r$$A8%NgkQj^al*D&D2|J?$_MRw)V)mYg)06sA>C#L!>S;xvCO^q0>5`Ac zH^zM`QvHz}4rN_p=1X#UbpfrgQMIn1+3e&_Phn}XyVUItM6$`+60*;Z4W{MIoNjKq zsF?Knh?BHqHto}f>(z{ZI(y<-$DFKR8@?M&|4(OcJZ+zoF46j<(G2U~Q+Q`)j@8zw z&lM5Tj6wCh)LXQ0Q0FkW`|7L*A~^1?e&QXi@_@7%&P~) UW5r+-^7m5*#CYucWi`w63s7;s>;M1& delta 765 zcmWmATUShB0LJlo+cWJw2W3KOCYgy+2&I!#Ne&?<6CtNPW*jGz2ZhjKzzZMz2w(+@P>hubq6Dk38f#FBGOR^8LI|S*>rjdHh#-n8Y`{iTqXwH$ zi#lw^7Hma5wqZMVpaG58i6%6o1+8eqF6_o0?8QE`qXYZVi38}uK^($i96>jZ;uwyj z2Pbe6|G(?SDa6r-(>Q~EoW(hu#{e$iA}--FuE0bBSCK>t*Ki#-a1(X^_Wkk$79?K4j zBJo+gGZsa^H)1Rp@6|xsu626S_OjRQa*3axIwRjjE|jq`?}Re1|1N6HT3qS9a+tPM z;Kcjn$AFko9#f8~NmCA~-=?hBn|#&2QlIXWB;+@t`CVc4D6YwJ-Ilb}f+fdU{-Ud;r1U$>Q!=Rk1AYNy_W%F@ diff --git a/backend/main.py b/backend/main.py index 4646339..fd91b1f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -313,18 +313,51 @@ async def get_picture(pic_id: int): except Exception as e: raise HTTPException(status_code=500, detail=str(e)) -# --- TTS 代理(有道美式发音 + 本地缓存) --- +# --- TTS 代理(Edge 神经嗓音首选 + 有道/百度兜底 + 缩写展开) --- TTS_CACHE_DIR = os.path.join(BASE_DIR, "db", "tts_cache") os.makedirs(TTS_CACHE_DIR, exist_ok=True) -def _build_tts_response(word: str): +# 选用嗓音:英式 RP(接近高考听力风格,免费、无需 key) +TTS_VOICE = "en-GB-SoniaNeural" +# 缓存文件名前缀:换嗓音/换引擎时旧缓存不会被误用(避免仍播放旧嗓音) +TTS_CACHE_PREFIX = "edge_sonia_" + +# 常见无歧义缩写 -> 完整读音(合成前展开,避免把 sth 拼读成三个字母) +_ABBREV = { + "sth": "something", "sb": "somebody", "sbdy": "somebody", + "esp": "especially", "etc": "et cetera", "eg": "for example", + "ie": "that is", "approx": "approximately", "info": "information", + "num": "number", "max": "maximum", "min": "minimum", + "ext": "extension", "dept": "department", "gov": "government", + "lab": "laboratory", "exam": "examination", "gym": "gymnasium", + "vs": "versus", "pref": "prefix", "suff": "suffix", "adb": "adverb", +} +import re as _re +def _expand_abbrev(text: str) -> str: + def _rep(m): + w = m.group(0) + return _ABBREV.get(w.lower(), w) + # 仅整词匹配替换,避免误改正常单词(如 clothes 不会被拆) + return _re.sub(r"[A-Za-z]+", _rep, text) + +async def _gen_edge_tts(text: str, voice: str) -> bytes: + import io, edge_tts + buf = io.BytesIO() + communicate = edge_tts.Communicate(text, voice) + async for chunk in communicate.stream(): + if chunk.get("type") == "audio": + buf.write(chunk["data"]) + return buf.getvalue() + +async def _build_tts_response(word: str): word = word.strip() if not word: raise HTTPException(status_code=400, detail="empty word") - # 缓存文件名:小写 + 下划线,避免特殊字符 - safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in word.lower()) - cache_path = os.path.join(TTS_CACHE_DIR, f"{safe_name}.mp3") + # 缩写展开后再合成(如 sth -> something),缓存也按展开后的文本 + spoken = _expand_abbrev(word) + safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in spoken.lower()) + cache_path = os.path.join(TTS_CACHE_DIR, f"{TTS_CACHE_PREFIX}{safe_name}.mp3") # 命中缓存直接返回 if os.path.exists(cache_path): @@ -332,29 +365,33 @@ def _build_tts_response(word: str): data = f.read() return Response(content=data, media_type="audio/mpeg") - # 多源 TTS:先有道(type=2 美式),失败回退百度翻译(本机可达,可覆盖有道无法合成的短语,无需 key) - sources = [ - ("youdao", "https://dict.youdao.com/dictvoice?audio=" + urllib.parse.quote(word) + "&type=2", - {"User-Agent": "Mozilla/5.0"}), - ("baidu", "https://fanyi.baidu.com/gettts?lan=en&text=" + urllib.parse.quote(word) + "&spd=3", - {"User-Agent": "Mozilla/5.0", "Referer": "https://fanyi.baidu.com/"}), - ] data = None last_err = None - for name, url, headers in sources: + # 1) Edge 神经嗓音(首选) + try: + data = await _gen_edge_tts(spoken, TTS_VOICE) + except Exception as e: + last_err = f"edge: {e}" + # 2) 兜底: 有道(美式) + if not data: try: - req = urllib.request.Request(url, headers=headers) + url = "https://dict.youdao.com/dictvoice?audio=" + urllib.parse.quote(spoken) + "&type=2" + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) with urllib.request.urlopen(req, timeout=10) as resp: - ct = resp.headers.get("Content-Type", "") - if ct.startswith("application/json"): - last_err = f"{name} returned json (no audio)" - continue - data = resp.read() - if data: - break - except urllib.error.URLError as e: - last_err = f"{name}: {e}" - continue + if not resp.headers.get("Content-Type", "").startswith("application/json"): + data = resp.read() + except Exception as e: + last_err = f"youdao: {e}" + # 3) 兜底: 百度 + if not data: + try: + url = "https://fanyi.baidu.com/gettts?lan=en&text=" + urllib.parse.quote(spoken) + "&spd=3" + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0", "Referer": "https://fanyi.baidu.com/"}) + with urllib.request.urlopen(req, timeout=10) as resp: + if not resp.headers.get("Content-Type", "").startswith("application/json"): + data = resp.read() + except Exception as e: + last_err = f"baidu: {e}" if not data: raise HTTPException(status_code=502, detail=f"TTS upstream error: {last_err}") @@ -371,13 +408,13 @@ def _build_tts_response(word: str): @app.get("/api/tts/{word}") async def get_tts(word: str): - return _build_tts_response(word) + return await _build_tts_response(word) @app.get("/api/tts") async def get_tts_query(w: str = ""): # 含 / 的短语(如 "in the/a form of")走查询参数,避免与 URL 路径分隔符冲突 - return _build_tts_response(w) + return await _build_tts_response(w) @app.post("/api/progress/update") async def update_progress(data: ProgressUpdate): diff --git a/frontend/public/sw.js b/frontend/public/sw.js index beef00e..94ba4c5 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -5,7 +5,7 @@ // - navigation (HTML) → network-first, 失败回退 cached index.html // - 静态资源 (JS/CSS/img/font) → cache-first -const CACHE_VERSION = 'words-pwa-v1'; +const CACHE_VERSION = 'words-pwa-v2'; self.addEventListener('install', (event) => { event.waitUntil( diff --git a/frontend/src/sync.js b/frontend/src/sync.js index 7009d23..99df80e 100644 --- a/frontend/src/sync.js +++ b/frontend/src/sync.js @@ -6,7 +6,7 @@ import { api } from './api'; import { - getDirtyProgress, clearDirty, getAllProgress, putProgress, deleteProgress, + getDirtyProgress, clearDirty, getAllProgress, putProgress, putWords, setMeta, getMeta, setLearnableGroups, getAllWordsFromIDB, getWordsCount, } from './db'; @@ -79,17 +79,12 @@ export async function syncNow() { } } - // 阶段2b: 服务端已删除(且本地已确认/非 dirty)的进度, 本地同步删除。 - // 仅在成功拉取到非空集合时才执行, 避免服务端异常返回空时误删本地进度; - // 仅删非 dirty 记录(本地未推送完成的进度绝不删, 保护离线工作)。 - if (serverEvents.length > 0) { - const serverKeys = new Set(serverEvents.map(e => `${e.group_id}:${e.op_type}`)); - for (const p of localAll) { - if (!p.dirty && !serverKeys.has(p.key)) { - await deleteProgress(p.group_id, p.op_type); - } - } - } + // 阶段2b: 不再自动删除本地进度。 + // 原逻辑会把"服务端 pull 中不存在的本地非 dirty 记录"删掉,用于"撤销"回写; + // 但布尔并集语义下,服务端 pull 缺失≠被撤销(可能是推送时序/服务端异常), + // 这会静默丢掉已完成的进度(如 Review2)。回归离线方案原始设计「不删本地」, + // 同步只做布尔并集(只补不删),保证任何已完成的 (group,op) 永不丢失。 + // 如需撤销,应在 /api/sync/pull 显式返回"已删除清单"再据此删除,而非按缺失推断。 // 阶段3: 首次 warmup 词库 (仅 IDB 空时, 避免每次重拉 1852 词) if ((await getWordsCount()) === 0) { -- 2.43.0