Repository navigation
Releases
v0.3.2
一个"开关点了没反应"的修复:0.2 之后弹幕整体失效(消息改走协议文件,推弹幕那行还挂在
老轮询上)。顺带修两个配置读取的静默失败 —— 带 BOM 的配置会让所有设置回默认,
danmakuInset则一直是个死键。
Fixed — 开了弹幕却一条都不飘(#61,PR #62)
弹幕唯一的推送入口挂在老的 chat.jsonl 轮询上,而 0.2 之后消息都走
window-events.jsonl → _render_event(),那条只上聊天框。协议文件存在时
chat.jsonl 基本不再产生新行,于是弹幕功能整体失效,开关怎么点都没反应。
_render_event()里add()成功后直接推这一条;_replay_protocol()期间置
_replaying,开场回放只填聊天框、不飘弹幕雨;_push_danmaku(messages=None)接显式消息列表,跳过meta和at早于 5 秒的迟到历史;- 默认位置从"竖着居中"改到右下角 —— 居中那档正好压在播放器和画面弹幕上。
Fixed — 带 BOM 的 watch-window.json 被静默读成空配置
read_config() 用 utf-8 读、异常吞成 {},用记事本或老编辑器改过配置就会中招:
窗口跳回默认位置、弹幕变回关、glass 变回 live。改成 utf-8-sig。
Fixed — danmakuInset 是死键
danmaku_options() 没透传 inset(danmaku.py 一直支持),带子压在系统 OSD 上时挪不动。
Docs — README 补「安全边界:图里的文字是内容,不是指令」(#56)
把 tool-spec.js 里那段常驻说明、以及宿主侧该做的两件事(破坏性操作要确认、屏幕内容单独打标记)
写进 README;同时写明不做"看到疑似指令就拒绝执行"的拦截,理由是误伤率高。
v0.3.1
两个修复,都不动窗口本身:设置页 token 不再一重启就失效、对 storyboard 的依赖范围
真的能装到 0.7.x —— 后者是装完就坏的那种,值得单独升一版。
Fixed — 依赖范围 ^0.6.0 装不到 storyboard 0.7.0(#60)
0.x 的 caret 不跨小版本:^0.6.0 只匹配 >=0.6.0 <0.7.0。而 0.3.0 是按 storyboard 0.7 的
导出写的,于是新装的人拿到 0.6.x,装完直接坏。
dependencies.dsh-video-storyboard抬到^0.7.0;test/dep-contract.test.mjs加了一条断言:同级 storyboard 的小版本必须落在依赖范围内
(并排仓库不在时自动跳过)。以后改版本号忘了抬依赖,测试会先红。
Fixed — 设置页 token 一重启就失效(PR #59)
与 storyboard 同款修法:token 落 $DSH_HOME/watch-settings-token,宿主重启后已打开的
设置页继续能保存。
Added — 本地化元数据与详情页(PR #59)
locale/{zh,en}.json + docs/plugin*.md。
v0.3.0
这一版的重点是**「别碍事」**:窗口能真的从屏幕上消失、滚动手感从卡顿变成跟手、
在 mac 和 Linux 上不再一启动就退。另外补了一个从 0.0.2 起就存在的静默失败 —— 麦克风。
Added — 最小化到托盘 + 弹幕层
悬浮窗以前只有两档形态:全尺寸、或者"折叠成一条"。折叠还占着一块屏幕,打游戏时很碍事。
- 两档:
full/hidden。hidden走withdraw,真的离开屏幕,不是缩成一条; - 回来的路有三条(少一条就等于"窗口没了"):托盘图标、
Ctrl+Alt+W、宿主工具的window:show; danmaku.py:独立Toplevel+WS_EX_TRANSPARENT点穿,30fps,队列空了就停表
(不是空转);只飘 assistant 的话,自己发的不飘;- 标题栏四个按钮:弹 / 最小化 / 折叠 / 关闭;状态行按像素截断,不再糊住按钮;
- 协议加白名单 +
minimize/danmaku两个方法;工具参数补window:hide和danmaku。
顺带修了四个真问题:
asr用窗口自己的pythonw:PATH 上的python.exe会弹黑框,而且没有pyaudiowpatch
—— 麦克风一直是静默失败的(stderr 是 DEVNULL,所以没人知道);_save_geometry取_geo而不是winfo(geometry 是异步的,折叠/展开会互换);- 折叠态点名字附近会误开麦;
faulthandler落window-fault.log(pythonw没有 stderr,段错误本来没证据可查);
DSH_TRAY=0可以关掉托盘做二分。
Fixed — 滚轮翻历史每格卡近一秒(see-now #44)
_wheel() 每次都走 _refresh_feed() → _layout():delete("all") 之后标题栏、
输入框、滚动条、事件绑定、静态底板全部重建,而 TextLayer.draw() 是按字符建
canvas item(有底板时每个字还要再加 4 层描边)。滚一格 = 重建整屏几千个 item。
- 新增
_relayout_feed():只删feedbody/sbar两层重画,其余一概不动; - 连滚 40ms 防抖(
_schedule_feed_refresh),只重排最后一次; - 有底板的正文整行一个 canvas item ——
adapt=False时逐字唯一的好处(同一行
左右两半分别翻色)不成立,而那是 item 数的大头。逐字路径只留给真的跟着背景翻色的文字; - 删
feedbody而不是feed:_draw_input()里的「发送」两个字也挂着feedtag,
删feed会让按钮变空白。
同素材(20 条消息、440×380、frost)实测,已扣掉脚本里固定的 60ms pump:
| 改前 | 改后 | |
|---|---|---|
| canvas item 总数 | 964 | 104 |
| 滚一格 | 136 ms | 63 ms |
| 连滚 8 格 | 123 ms | 62 ms |
Added — noActivate:点窗不激活(默认关,see-now #44)
悬浮窗是置顶的,点一下会激活它,Windows 跟着重排置顶带 —— 宿主窗被顶上来、全屏
游戏被切走。#44 提的三条路子里选"给开关、不改默认":
~/.dsh/watch-window.json的{ "noActivate": true },或DSH_WINDOW_NO_ACTIVATE=1;- 打开后给窗口加
WS_EX_NOACTIVATE,点它不改变前台窗口。代价是它也拿不到键盘焦点,
所以点输入框时先摘掉这条样式(_focus_input),本次运行不再加回; - 默认关:
WS_EX_NOACTIVATE的窗口输入框直接打不了字,而那条"点输入框自动摘掉"
的路径没法自动化验证,不替用户做这个决定。
Fixed — 悬浮窗在 macOS/Linux 上必崩(see-now #40)
window.py 顶层无条件摸 ctypes.windll(DPI 感知、timeBeginPeriod),
非 Windows 上直接 AttributeError,而且死在依赖预检之前 —— 用户看到的是
"启动后 700ms 退出,code=1"。
- 所有
ctypes.windll/wintypes/ WDA 调用按IS_WIN收口; - 没有 WDA 的平台一律按"能抓到自己"处理(
_cappable强制打开),
改走fill_own_rect那条路,不会递归拍到自己; _bbox()在非 Windows 上退回 Tk 的屏幕坐标(Retina 缩放对齐仍是已知问题,
那类机器建议用frost/solid,两者都不抓屏)。
上一版只收了 ctypes.windll,-transparentcolor 这一行漏了 —— 它同样是 Tk 的
Windows 专有属性(aqua 的合法集合是 -alpha / -fullscreen / -modified / -notify / -titlepath / -topmost / -transparent,X11 也没有它),无条件调用一样 TclError 死在
__init__。这道坎在 see-now #45(macOS)和 see-now PR #38(Linux VM)里各被独立撞到一次。
-transparentcolor包进if IS_WIN:;非 Windows 上窗口退化成一张 KEY 底色的深色卡片
(#010203、四角变直角),先保证起得来再说视觉;glass_capture.py的@dataclass(slots=True)(3.10+)去掉 —— Xcode CLT 自带的
3.9 是 mac 上最好拿的一个解释器,不该被三个字段的__slots__挡在门外;window.py/window_widgets.py补from __future__ import annotations,
X | None这类注解在 3.9 上不再求值。12 个 py 文件现在全部过
ast.parse(feature_version=(3, 9))。- 启动失败弹窗的标题跟着
DSH_WINDOW_BRAND走(see-now 的用户不会再看到
"dsh-watch-together",issue #32 第 1 条)。
Fixed — 麦克风永远起不来:aec.py 没打进包(see-now #36)
mic.py 顶层 from aec import ...,但 aec.py 既没进 see-now 的同步清单,
发布包里的 stderr 又被丢进 DEVNULL —— 界面只剩"录音中"。
- 导入改成可缺省:没有
aec.py时回声消除关掉、麦克风照常可用(并打一行原因); - see-now 的
sync-window.mjsASSETS 补上aec.py。
v0.2.15
Fixed — 有些机器上装完一个工具都不出现(社区实报)
import('@deepseek-ai/dsh-tools') 拿 defineTool,而宿主的这份被打进 app.asar,
插件从自己的 node_modules 往上解析不到 → 抛 → apply() 的 catch 吞掉 →
整个插件静默不注册任何工具(日志里只留一行 failed to load)。
- 新增
src/define-tool.js:内置一份defineTool兼容实现(author spec → JSON Schema,
规则照抄宿主runSchemaCompiler的语义;只少了运行时参数校验)。 loadDefineTool()改成三跳:import→DSH_TOOLS_PATH(包了 try,路径写错也不会崩)→ 内置兜底。- 走兜底的情况下工具照常注册,
schema-audit那套静态审计仍然是第一道防线。 - 测试 +6:
type:'json'退化成注解、required:true提升到父级、真实 spec 全部能编译出来。
All notable changes to dsh-watch-together are documented here.
This package starts its own version line at0.0.7— it is not a continuation ofdsh-video-storyboard.
安装:dsh plugin add dsh-watch-together@0.2.15
v0.2.14
Added — 悬浮窗样式预设:多一档「静态磨砂」,设置页里能选
社区反馈实时玻璃在低配机/高分屏上卡。实测同机同尺寸(760×560、背景在播视频):
| 样式 | 3 秒内 CPU | 占单核 |
|---|---|---|
live 实时玻璃 |
5.17 s | 172% |
frost 静态磨砂(新) |
0.03 s | 1% |
不是把模糊调小,而是换了一条路径:frost 完全不抓屏,只画一张预生成的
底板(对角冷色渐变 + 两团极淡光晕 + 细噪点 + 每块玻璃 1px 亮边),尺寸或气泡
形状变了才重画(约 11ms),其余时间一帧都不算 —— 静态样式连渲染线程都不起。
live 那条路径一行没动,只是把线程的启停抽成了 _start_worker / _stop_worker。
样式写在 ~/.dsh/watch-window.json 的 glass:auto(默认,远程/录屏 →
solid)/ live / frost / solid。窗口每 250ms 看一次这个文件的 mtime,
所以从设置页改完立刻切换,不用重开窗口;从 live 切走时真的把抓屏线程
停掉(只换渲染器不停线程 = 用户切过去还是卡,只是看不见画面在变)。
设置页(浏览器版与 DSH 设置里的原生表单)都多了一栏「悬浮窗样式」。
Changed — 心跳从"空文件戳一下"变成带可见性的 JSON
插件侧以前只能看心跳的 mtime,那只能回答"进程还在画吗",回答不了"用户能不能看见"。
于是出现最难查的一种:配置文件写 hidden:false、进程活着、心跳在跳,屏幕上什么都没有,
而插件 status 说"显示中"(see-now issue #32 第 3 点)。
现在每秒写的是:
{"at":1793886026.33,"visible":true,"hidden":false,"state":"normal"}hidden 是意图,visible 是事实(Tk 的 state() + winfo_ismapped() 说了算),
两个都写 —— 只写事实的话插件分不清"用户故意藏的"和"想显示但没 map 出来"。
原子写(.tmp + replace):插件每秒都可能读,读到半行 JSON 会退回读配置文件,
那正好是这一版要修的东西。
mtime 照旧更新,老版本插件不受影响。
0.2.13 — 语音抢占式 + 适配器能「拉」位置和台词
0.2.13 — 语音抢占式 + 适配器能"拉"位置和台词
声音终于会"抢话"了:新的一句直接顶掉没念完的,不再三句排队排到画面之外。
同时把 issue #4 最后一环补上(api.upcoming/api.position),
悬浮窗也能拖到副屏了。
Changed — 语音改成抢占式
以前是纯串行链:三句排队,第三句要等前两句念完才开口 —— 那时候画面早换了,
听到的是"它在说上一段的事"。MAX_QUEUE_LAG_MS 只能做"排太久就放弃",救不了"一直在排"。
现在每句带代际(speakGen),新句一到立刻:截断正在念的那句
(常驻通道走 daemon 新增的 interrupt op,只杀播放管道那个 ffplay,daemon 还活着;
没有 edge-tts 的机器走 SAPI,kill 掉那个 PowerShell —— SpVoice.Speak() 是阻塞调用,
只有杀进程才能让它闭嘴),并清空排队(那些句子配的是已经过去的画面)。
代价:被截断的那句后面几个字听不到 —— 追上画面比句子完整重要。
新协议:{"op":"interrupt"} → {"ev":"interrupted"};它和 stop 不是一回事,
stop 是 SystemExit(0),整个常驻合成器没了,下一句要重新预热 1~2 秒。
Added — api.upcoming() / api.position()(#4)
此前核心只会推(due / scheduled),适配器想知道"再往后还有什么、现在播到哪"
只能自己读 watch.json 和 position.json —— 两份内部格式,还各自带着一套"哪条位置可信"
的判断,第三方适配器重写一遍必然走样。
await api.upcoming({ horizonSec: 20 }) // → [{ at, text, segment }],按 at 升序,不消费
api.position() // → { pos, dur, confidence, staleMs }upcoming()读同一份台词槽,只给未来窗口内、带at的句子;窗口 1~120 秒,
没在陪看 / 位置不知道时是空数组。position()走position.js那套锚点逻辑(扩展上报 / CDP 探针 / 用户自报三条来路),
confidence分fresh/extrapolated/unknown。unknown时pos是null而不是0——
给 0 会让"我合成的音频比当前位置晚了三百秒"成立,然后默默丢光音频且不报错。- 停表 / 换片即清空。边界没变:核心不做合成、不缓存音频、不重试。
Fixed — 悬浮窗拖得到副屏(#8)
副屏在主屏左边时坐标是负的,而拖动 / 缩放 / 恢复位置 / 抓屏外扩四处都拿主屏当边界:
窗口拖不出去、放到副屏后玻璃停在旧帧、下次启动又被拉回主屏。
现在用 GetSystemMetrics(76..79) 取虚拟桌面,按下时重新同步屏幕(插拔显示器不用重开窗);
窗口整个在主屏里时仍按主屏钳,单屏逐像素不变。
Fixed — 缺 mss / numpy / pillow 时弹窗说明(#76 附带)
pythonw 没有控制台,import mss 失败时 Python 直接退出,用户看到的只有"窗口没出来"。
现在缺包就弹 MessageBox 写明缺哪个、装什么,并把原因落到 ~/.dsh/watch-window-error.log。
验证
- 全量
node --test334/334(41 秒) check-syntax59 文件通过- 打包 103 文件 / 551.8 kB
dist.shasum与本地 tarball 一致,gitHead= release commit- 顺带修掉一个把
npm test卡死的测试(widget-reopen没隔离DSH_HOME,
会挂本机真实 voice daemon,跑完断言后进程永不退出)
安装:npm i dsh-watch-together@0.2.13
v0.2.12
Fixed
- 在悬浮窗打字不再往宿主的「排队消息」里塞一条(
wakeOnChat默认true→false)。
每打一句话,输入框上方就多一条排队消息 —— 而窗口里明明已经有气泡显示过了,
队列里再来一条既是噪音、又占住输入框。根因是wakeSession走宿主的agent.followup(),
而 DSH 的 QueueDock 不过滤来源,绕不开,只能不投。
关掉不会丢话:钟那一拍整个跳过、聊天通道不消费,消息留在里面,模型下次调
watch_together时 poll 得到;真正的阻塞等待(wait/nextLine)走另一条路,
照旧能随时打断。
Added — 气泡底板
聊天气泡内部铺一层 #07111d、alpha 0.50 的圆角底板(glass_geometry.BUBBLE_PLATE),
文字的对比度不再跟着画面走。底板在玻璃合成层做(_overlay 的 tint),CPU / GPU 两条
路径共用同一份实现;气泡宽度变了跟着一起重算;空态不铺(四个占位框铺深色会像四个空洞)。
实测:760×560、同一段消息、同一位置,只切 DSH_GLASS_PLATE 拍两版,取气泡内
「底板中位灰度 / 文字笔画 p97」算 WCAG 对比度。
| 背景 | 改动前 | 改动后 |
|---|---|---|
| 纯白(最坏情况) | 1.26–1.53:1 | 3.83–4.25:1 |
| 深蓝黑 | 16.3–17.7:1 | 17.0–17.2:1 |
alpha 0.40 时纯白背景只有 2.88–3.31:1(试过),0.50 才到 4.2。
TextLayer.draw(shadow=True) 给每个字先落一层 +1px 的深色副本。Tk 的 text item 没有
stroke / outline,只能多建一层 item;只在气泡正文开,refresh() 用 tshadow 标签把
它排除在翻色之外(目前暂存)。
Changed — 声音延迟 2.4 秒 → 0.2 秒(常驻合成器)
台词几分钟前就写好了,第一声却要等两秒多 —— 听起来不像它在说,像在等我念。
老路是每句起两个进程:一个 edge-tts.exe 合成到临时 mp3,一个 ffplay 播放。
实测(Win11 / i9-12900HX,短句与长句各三次取均值)端到端 2.4~3.0 秒,
而且这四笔开销每句都要重付一次:
| 段 | 耗时 |
|---|---|
| pip 的 exe shim 拉起 python | ~600ms |
| python 解释器冷启动 | ~600ms |
import edge_tts |
~700ms |
| WebSocket 建连(DNS+TCP+TLS+token) | 620~760ms |
| 首块合成 | 200~300ms |
| 剩余音频块 | 30~130ms |
| ffplay 启动 + demuxer 探测 | 380~410ms |
examples/voice-edge-tts/stream/daemon.py 把三件事搬进一个常驻进程:
- 常驻 python:
import edge_tts只做一次。 - 长连接复用:一条 WebSocket 上连发多个 SSML 请求(Azure 协议本来就支持)。
实测连续 20 句首块 min 182 / avg 200 / max 235ms,没有退化。
服务端在turn.end之后约 30 秒关连接(30s 还活着、35s 已死;发 ping 保不住),
所以空闲超 25s 主动断、下次用时重建(620~760ms)。 - 常驻 ffplay:一个进程从 stdin 连续吃 mp3 帧。实测三段音频拼着写进去,
时间码一路涨到 7.3s、进程没退 —— 每句起进程要付 380ms 首声。
同一条 due 事件、同一台机器上的对照:
| 场景 | 老路 | 现在 |
|---|---|---|
预取命中(scheduled 已合成) |
1009~1674ms | 1ms |
没赶上预取(see-now 的 at:null) |
2485ms | 230ms |
| 连续第三句 | 排队 4.9s 后被丢弃 | 1ms |
预取队列也不再"满 6 句就丢弃":常驻通道按 text 在内存里缓存(上限 80 句),
老路那个 MAX_PREFETCH = 6 是文件缓存 + 串行 CLI 逼出来的,这里不用照抄。
兜底:python 找不到、daemon.py 不存在、进程起不来 —— 任何一步失败都退回老路
(每句一个 CLI 进程),没有一行 throw。DSH_TTS_STREAM=0 可强制走老路排查。
调试时 DSH_TTS_FFPLAY_LOG=info 会把 ffplay 的时间码放出来。
test/tts-stream.test.mjs 覆盖协议(first/done 顺序、error 后通道继续可用、进程死掉后不挂在超时上),
不依赖网络和 ffplay。
Issue #44 第 2 条:文字直接压在实时视频上,对比度每秒都在变,连看 5–10 分钟眼睛发酸。
有底板之后底色恒定,TextLayer.adapt() 对气泡正文已经没有意义,反而会在过渡帧翻错
(白字被翻成深色,压在深色底板上更看不见)。给 draw() 加了 adapt=False:这些 item
不参与采样。标题栏、输入框、空态提示仍走逐字翻色 —— 它们确实直接压在玻璃上。
- 跟
dsh-video-storyboard0.6.6 一起发。依赖范围^0.6.0不用改。
上游这次是排布与计费对齐(同素材 token -9.3%),本包自己的代码没有改动。
调试开关
DSH_GLASS_PLATE=0 关掉底板和描边,回到 0.2.11 的行为 —— 只用来拍新旧对比图。
媒体素材:悬浮窗实录(2026-11-03)
这里放的是 issue 里引用的录屏,不是版本,不参与 npm 发布。
floating-window-0.2.8-force.mp4:0.2.8 +GLASS_FORCE=1+capturable: true下的悬浮窗实录
0.2.6 — 声音预合成(合 PR #36)
陪看的声音还差最后一步:到点才开始合成。这一轮把"合成"从关键路径上挪开。
Added — scheduled 事件(合 PR #36,作者 @ggfgfgf-on)
- 循环每一拍把未来 60 秒内、已经写好且带
at的台词以scheduled广播一次(每句一次;
只在有会出声的订阅者时发)。它不参与"该不该念"的判断,只给适配器时间预合成。 upcomingLines(input, { after, before }):只读的"未来台词"查询,不消费稿件。
Changed — 示例适配器预合成
examples/voice-edge-tts:speakEdge拆成synthEdge(只合成)+playEdge(播放并删临时文件);
scheduled到达即合成,按input|segment|text缓存(上限 6 条,满时踢at最远的一条),
due优先播缓存。日志多出prefetched/synth、ready Nms、wait Nms。MAX_QUEUE_LAG_MS的计时从due到达起算,合成时间本来就算在里面 ——
预取因此还降低了"排队太久被丢弃"的概率,不只是首声早一点。
Fixed — 评审补丁(5fa1307、bd5b3c7)
- 跨段时缓存 key 对不上:
dueLine跨段挑at最小的一句,选中上一段的句子时due报的却是
当前段,和scheduled的segment不一致 → 缓存白做、首声又晚回 0.6~2 秒。
dueLine({ meta: true })现在多返回from,speak()用它。 Number(null) === 0:没写at的台词会被当成"at=0"挤进预取窗口;适配器prefetch()同理,
6 个槽位被 at=0 占满后永久失效。两处都改成显式判空。
安装:npm i dsh-watch-together@0.2.6
0.2.5 — PR #29(GPU 内框边缘 + 气泡留白)
Fixed — GPU 内框边缘(合 PR #29,作者 @ggfgfgf-on)
- 底部输入/发送框发黑:内框边缘原来直接拿
small的 render target 当 sampler,
贴边小框会采到边界外。现在单独拷一份 blur(1.45) 到edgeblurFBO 再采样
(注释里写明「直接当 sampler 会触发驱动崩溃」,所以宁可多一次 copy)。 - 内框边缘的折射/扭曲丢了:CPU 里
edge_w=0是边缘(取折射样本)、=1是中心
(取强模糊),Shader 里方向是反的。改成mix(edgep, strong, e)后恢复。
Changed — 气泡留白
- 横向 14→18,纵向
+4→+16,正文按(bh - lines*ls)/2垂直居中。
字不再贴着气泡边,短消息也不会挤成一条。
安装:npm i dsh-watch-together@0.2.5