Skip to content

Releases: SAOS-3624/dsh-see-now

v0.1.1

Choose a tag to compare

@SAOS-3624 SAOS-3624 released this 04 Nov 11:53

为什么跳过 0.1.0:这个版本号在 11-03 14:26 被误发过一次(内容不可用,已标废弃)。
npm 的规则是「用过的 package@version 永远不能再用,即使你已经把它 unpublish」,
所以这一版记为 0.1.1。

Changed — 台词早 1.3 秒出现:长句边说边出字幕

社区反馈"字幕比画面晚 510 秒"。拆开量了一遍,**转写本身只要 100180ms**,
时间全花在"等断句"上:

开口 ──等静音 0.35s / 等强切 3.0s──▶ 落盘 ──轮询 200ms──▶ 转写 180ms ──▶ 可读
        └────────────── 这一段占了 90% ──────────────┘

实测(本机 paraformer-zh,真实旁白素材):

音频长度 转写耗时
1.0s 99ms
1.5s 139ms
2.0s 158ms
3.0s 174~181ms

等断句的时间是转写本身的 20 倍。 所以不再等:

  • 长句说到 1.8 秒就先转一版(live-<t0>.wav,同名覆盖),整句落盘后用准确结果
    覆盖它。真机实测:快照在开口后 2.08s 落盘、整句 3.30s —— 首版字幕提前 1.2 秒。
  • 轮询 200 → 100ms,净省 100ms。
  • 快照是"说了一半",只在句末加「还在说…」标记,整句永远盖过快照:
    createLineBuffer.upsert() 按 t0(句首墙上时间)认同一句,迟到的快照不许把整句改回去。

出图前等最多 300ms(listener.settle()):台词的取数窗口是「上一张图的 wallTo →
这一张的 wallTo」,差几百毫秒没转完的那句会被推到下一张 —— 那就是十几秒,
而 ASR 本身只有 0.7 秒。边界效应才是"字幕晚十几秒"的主因之一。
积压(seg ≥ 3 个)时不参与等待,免得拖慢出图。

顺带修的

  • 本地转写服务自己睡了没人管:asr.json 的 manage.idleMs 常配成几秒,视频安静
    一会儿服务就退出,再说话时第一个请求必然失败。旧逻辑要等 3 次失败才拉起(白丢 30 秒
    字幕),现在第一次失败就 spawn(不 await、60 秒内不重复拉)。
  • status 里加了延迟诊断:上一句开口→可读 X.Xs、临时字幕 N 次。
    以后问"怎么半天不出字幕"直接看这两个数,不用猜。
  • 快照转写失败不静默了(记一条 log),但不计入连续失败 —— 它只是"提前给一版",
    挂了不该把主链路暂停 30 秒。

参数走 ~/.dsh/see-now.json 的 listen 段(改完重开 DSH):

{ "listen": { "live": true, "liveMin": 1.8, "liveStep": 1.2,
              "silence": 0.35, "maxSeg": 3.0, "pollMs": 100 } }

live:false 退回纯整句(省一半转写调用);liveMin 别低于 1.2
(1.0 秒的片段实测会丢一半字)。

⚠️ 还有一段延迟不归 ASR 管:一张图覆盖 15 秒(maxWaitSec),第 3 秒说的话
要等第 15 秒出图时才随图交付 —— 平均 7.5 秒。要更跟得上画面就压 maxWaitSec
或改用 pace:"dense",代价是 token 按比例涨。

Performance — 打分快一半、出图快四成,顺手清掉一批死代码

实时链路里最贵的两段都是纯 JS(打分每帧跑、出图每 15 秒跑一次)。这次把里面的
浪费拿回来,画面逐字节不变:同一批素材新旧实现输出的 PNG 完全一致,
打分分数逐帧相等。

环节(798×450,i9-12900HX) 改前 改后
打分 · 每帧 1.84 ms 0.95 ms(-48%)
打分 · 静止画面 1.30 ms 0.50 ms(-62%)
出一张 4 帧图 73 ms 42 ms(-43%)
出一张 1 帧图 62 ms 39 ms(-37%)

出图那两行是同一进程内新旧实现交替跑、每次前强制 GC、取 15 次中位数 ——
绝对值会被 GC 放大,看比例就好。

打分(cuts.js)

  • mcStats:先测同位置再扫邻域。旧写法 dy 从 -radius 开始,radius=4 时
    同位置排在第 41 个,静止画面每格白算 40 次才命中。屏幕最常见的状态就是"没变",
    这一条单独省掉 90% 的静止帧开销。
  • bgStats:每个格子原来都 new [0,0,0] 一次并调 TypedArray#sort
    (2304 格 × 12fps = 每秒 2.7 万个短命数组),改成复用缓冲 + 手写插入排序
    —— m ≤ 8 时插入排序比内建快一倍多。

出图(scale.js / sheet.js / pipeline.js)

  • 帧直接缩进画布(coverIntoAt):不再"每格 alloc 中间缓冲 → coverInto →
    blit 到画布",一张 4 帧图省掉 4 次 267KB 分配 + 4 次等大拷贝。
  • 1×1 / 2×2 内联:SEAM 会把"整幅铺一格"变成 797×449 这种差 1 像素的尺寸,
    旧代码让每个像素都进通用平均循环求 1 个点的"平均"(还带一次除法)。
  • 轴映射表按 (源长, 目标长, 裁切长) 缓存,同一档清晰度的每格尺寸都一样。
  • 画布底色从逐像素循环改成 buf.fill(Buffer) 一次原生填充。
  • 空洞掩码只在真有空洞时才建:4 帧 2×2、单帧铺满都覆盖整张画布,扫 36 万像素是白干。
  • 音轨条就地画(drawAudioStrip):composePacked 直接把条高预留进画布,
    不再"分配一张 1.1MB 的新图 + 整图拷贝"。
  • 点阵编号直写像素:旧版每个点亮像素都调一次 fillRect,一次要做 4 次 clamp +
    2 次边界判断,画一个两位数就是 200+ 次函数调用。

采集(capture.js)

  • 拼帧从 Buffer.concat([pending, chunk]) 改成"一次 allocUnsafe(need) +
    按需 copy"。旧写法 pipe 每给 64KB 就拼一次,一帧 1.03MB 要拼 16 次,
    累计拷贝约 8.5MB/帧(12fps 下 100MB/s),全是白干。

顺手修的

  • 文件源放完被当成异常(capture.js)。source:"file" 播完是 code 0,而
    close 里除了 code === null(我们自己 kill 的)一律 reject。后果不是"多一条日志":
    runner 只在 .then() 里写 endedSource,走 reject 分支那个硬闸就永远不生效 ——
    下一次 watch 会把同一个文件从头再放一遍(社区实报的"24 秒解 1955 帧、
    刷几十张图、关窗也停不干净")。scripts/sim.mjs 也因此在最后一步抛异常。
  • DEFAULTS 补上 floorMin / floorMax。三档节奏都显式传了,但直接
    createPipeline({floorGapK: 2}) 时它们是 undefined → 兜底间隔算出 NaN →
    elapsed >= NaN 永远 false → 画面静止时兜底帧一帧都不出。静默坏死,最难查。
  • index.js 的 loopLine / cookieWarnLine / listenLine 从 route() 函数体内部
    提到模块顶层(原来靠函数提升才没报错,缩进还是 0)。

删掉的死代码

pack.bestPlan、sheet.headerHeight、capture.ENGINES、speech.speechObservers、
chat.lastSpoken / __resetSpokenLog / __resetChatWatcher、pipeline.plan0、
stats.msDecode / stats.msTotal / pipeline.dropped,以及 index.js 里两个
从未使用的 import。

Changed — 台词追得上画面了:断句窗口砍半,台词直接标帧号

用户实报"字幕比画面落后四五秒"。那不是转写慢(130~250ms),是断句等太久:
原来连续说话要攒到 6 秒才强切,加上静音判定 0.45 秒,最坏情况下台词要等
6.5 秒才出现 —— 画面早换了。

参数 改前 改后 效果
max_seg(强切) 6.0s 3.0s 最坏延迟腰斩
silence(判定说完) 0.45s 0.35s 短句更快落盘
扫目录 350ms 200ms 平均省 75ms

从"说完最后一个字"到"模型能读到":约 0.6~0.7 秒(静音判定 350 + 扫盘 100 + 转写 150)。
从"开口"到"能读到":最长 3.35 秒(原 6.45)。

台词直接标帧号:每条前面加 #n,表示这句最接近第 n 格画面 ——
模型不用再拿时间码自己算,一眼知道"这句配那张图"。

台词(…):
  #2  15:57:32 / 9.3s  评价一个镜头的好坏,一般来说有两个维度
  #2  15:57:36 / 13.4s 第二个维度是艺术维度

代价:3 秒强切比 6 秒更容易把一句话切成两半("第一个维" / "度是技术")。
实时场景下"追上画面"比"句子完整"重要 —— 碎句模型能自己拼,晚 6 秒就救不回来了。

Changed — 插话不再被冷却挡住

自动唤醒的 3 秒冷却是防刷屏的,套在人的输入上就是画蛇添足:你说完一句、
3 秒内再补一句,第二句被静默吞掉,然后以"排队消息"的形式堆在宿主里 —— 比不叫醒还糟。

现在插话完全不受冷却限制,只有"同一拍连发"(500ms 内刚叫醒过)才放回队列等下一拍。

Fixed — 本地转写服务从来不会自动拉起("看不到字幕"的真因)

2026-11-04 实报:重启后模型说"一次都没收到台词"。查下来是三个 bug 叠在一起:

  1. asrConfig() 把 manage 丢了。它只挑了 baseUrl / key / model / language 四个字段,
    而"服务没起时怎么拉起"全在 manage.start 里 —— 于是 ensureService() 永远报
    asr.json 里没写 manage.start,服务永远不会被拉起。
  2. httpTranscriber 把异常吞成空串。catch 里 return '',上层只当"这段没人说话":
    失败计数永远是 0、不会停、status 永远显示"在听、0 句"。服务根本没起的时候,
    界面上和"视频是默片"一模一样。
  3. 失败 3 次直接 stop()。而最常见的失败(服务没起 / 正在冷启动 30 秒)本来会自己好,
    停掉等于永久放弃,还会连带 kill 采集进程、把音量条一起弄没。

修完的行为:

  • 开始听时先探 GET <origin>/health,不通就按 manage.start 拉起(不 await,不挡采集;
    实测 5.1 秒就绪)。只管拉不管关 —— 服务自己有 idleMs。
  • 转写失败改成暂停 30 秒后重试,文件留着不删(服务起来后接着转),目录最多留 40 段。
  • status 的「听声音」那行会直接写 在听但转写停了:<原因>,不再是含糊的"在听、0 句"。

Added — 图底下长出音轨条,台词带上本机时钟

上一版能听见了,但"听见了什么"只在文本里。这一版画到图上、把时间码写在台词旁边。

  • 音轨条(sheet.js 的 addAudioStrip):图底下接一条 width×0.055 高的带子
    (高度 = 画面占行后剩下的余量,默认档 12px)。三样东西:响度柱(青绿,一眼看出是安静旁白还是配乐轰鸣)、
    静音灰块(没人说话处铺深灰,柱子压在上面 —— 早先试过"静音不画柱子",灰块会变成
    一根根实心竖条把波形切断)、切点刻度(顶部黄短线,via==='cut' 的时刻,和宫格编号对得上)。
    配色和 dsh-video-storyboard 逐字节一致。
  • 音量数据是白捡的:listen.py 的 LevelMeter 复用断句已经在算的 RMS,每秒写一次
    levels.json(0.1 秒一格、留 180 秒)。不像 storyboard 要再开一次 ffmpeg 跑 ebur128。
    纯音乐没人说话、甚至转写服务挂了,音量条照样有。
    关键:levels.json 按时间格子存,静音格子显式补 -120dB —— WASAPI 静音时一个字节都不推,
    照"有数据才写"出来的序列在时间轴上是断的,画成条就是"柱子之间一片黑"。
  • 台词两个时间都给:15:57:32 / 9.3s。本机时钟给用户回头找(实时看屏幕没有"视频进度",
    浏览器放到第几秒我们不知道);相对秒和每帧那个 24.1s 同坐标系,模型才能把话对到格。

Changed — 画布宽度对齐 42、音轨条改成"白捡的",每张 254 → 222 token

两刀,都不损失东西:

① 宽度向下对齐 42(800→798、1066→1050、1280→1260、1600→1596)。
800 会被 pad 到 812 = 19.3 格,白算一整列;798 正好 19 列,面积只少 0.25%。

② 音轨条不跨行。token 是按 42px 一行的阶梯函数:450 高的画面占 11 行、上界 462,
底下空着 12px 也照样收 11 行的钱。所以条高取"这段余量"(450 余 12、600 余 30、720 余 36、900 余 24),
画面一点不少、条照样有、token 一行不涨。

档位 改前 改后
low(默认) 800×494 = 254 798×462 = 222(-12.6%)
standard 1066×659 = 434 1050×630 = 392
high 1280×790 = 610 1260×756 = 560
ultra 1600×988 = 962 1596×924 = 860

对齐 + 白捡之后,222 比加条之前的 233 还低(因为宽度省了一列)。

副作用:low 档的条只有 12px(柱子最高 9px)。看得出"哪里有声音、哪里安静、切点在哪",
但看不出精细包络 —— 这是"不跨行"的硬约束,想要粗一点的条就得占一整行(+20 token)。

LONG_EDGE(region 模式的单格长边)故意不动 —— 那个尺寸是 cellSize() 按区域比例算的,
本来就不是 42 的倍数,改它只会连带 2/4 帧拼图,收益是个位数 token。

想更干净就打掉音轨条(但不省钱)

audioStrip: false → 798×450 = 222 token,和带条一模一样(450 和 462 都在 11 行里)。

Fixed

  • flushNow 补的"此刻那一帧"没带 wall → sheet.wallTo 是 null,最后一张图取不到台词。

Added

  • 按"上一句说了多长"排下一次叫醒。固定间隔(calm 30s / normal 15s / dense 8s / 逐场景 4s)
    是从叫醒那一刻起算的,跟上一句说了多久没关系:上一句长的时候,下一轮会在它还在说时被叫起来。
    现在默认跟随上一句——下一轮最早落在「上一句说完 + 0.8 秒气口」,并保留 3 秒地板防连珠炮。
    想保持旧节奏写 "speechPacing": "guard"(取旧间隔与"说完+气口"更晚的那个),写 false 完全关掉。
    用户插话不受限制。时长按文本估(中文约 5 字/秒、英文约 14 字/秒,±20% 足够),
    不需要问 TTS 引擎要音频时长。有声音适配器时以 spoken(开始播放)为起点;
    没有声音时以 due 为起点,避免文字刷屏。status 的循环行会显示
    上一句约 X.Xs 和 下一轮最早 Ns 后。

v0.0.10

Choose a tag to compare

@SAOS-3624 SAOS-3624 released this 03 Nov 18:49

Added — 它开始听得见了:视频里的台词跟画面一起交给模型

之前只看画面:视频里的人在说什么、旁白讲到哪,模型全靠猜。
see_now 返回的清单里现在多一段 台词,时间码和每帧那个 24.1s 是同一个坐标系,
模型能把"这句话"和"第几格画面"对上。

链路:examples/floating-chat/listen.py(WASAPI loopback → 16k 单声道 → 按静音断句
→ seg-<epochMs>.wav)→ Node 扫目录 → 宿主 speechToText 或 ~/.dsh/asr.json 的
OpenAI 兼容服务 → 台词缓冲 → runner.attachLines() 挂到 sheet 上。

实测(i9-12900HX / Win11):抓回环绕 0.3% 单核,转写 3 秒段 130–160ms、
10 秒段 400ms;按静音断句,一句话说完到文字可用约 1 秒。
对比画面侧 ddagrab 0.18 核 —— 音频是零头。

三级降级,永远不会让"看屏幕"起不来:宿主 speechToText(DSH 桌面版自带
SenseVoice,社区用户零安装)→ 本地 HTTP → 都没有就只出图不出字幕,
status 里写明原因(听声音:没在听 —— …)。关掉:DSH_SEE_AUDIO=0。

  • "只听"模式:循环收工之后,窗口里的输入不再扔进黑洞。以前 idle 或连续叫不醒就
    dispose() 整条循环,可那扇窗还摆在屏幕上 —— 用户之后打的字没有任何人读,他当然
    以为自己在跟它说话。
    现在 idle 时先看窗还在不在(idleAction()):窗开着就只停采集、留着耳朵(mode: 'listen'),
    窗没了才真收工。窗口里一开口立刻回到 active 唤醒模型。
    采集不会自动重启 —— 模型要看图会调 peek,那条路才按用户原来的 region / 清晰度起管道。
    为此加了 pausedByListen 标志:退出只听后的第一拍会看到 !alive,那本来是我们自己停的,
    没有标志就会被判成 capture-stopped,用户刚说一句话循环就整个收工。
    status 里也会显示「循环:只听(…)」。

  • 缺 Python 包时打印安装命令。窗口脚本要 Pillow / mss / numpy,缺任何一个都是
    "启动后 700ms 退出、code=1",而插件以前只把 code 原样抛出去。现在启动全失败后会跑一次
    import 探测,把缺的包名和 python -m pip install ... 一起报出来。

  • test/pack.test.mjs:四条排布测试(容差、flat 判据、缓存键、部分拉伸上限)。
    这套原来缺位 —— 三个新行为改回旧代码,138 条测试一条都不红。

Changed

  • see_now 返回的文本多一段台词,status 多一行「听声音」。
  • pipeline.js 的帧多带一个 wall(epoch ms),sheet 多 wallFrom / wallTo ——
    台词只有时间码没有帧号,对齐只能靠同一个时钟(t 是"第几秒",掉帧就漂)。

声音适配器和 dsh-watch-together 共用同一个模块(~/.dsh/voice.json 只写一份),
所以那边把 examples/voice-edge-tts 换成常驻合成器之后,这里一起变快:

场景 之前 现在
say 一句(at: null,没有预取窗口) 2.4~2.5 秒 ~230ms

see_now 的事件仍然发 at: null 的 due —— 它没有时间轴,本来也预取不了。
变快不是因为提前知道台词,是常驻进程省掉了"每句重付 python 启动 + import 建连 + ffplay 启动"
这四笔固定开销。协议和 spoken / skipped 的语义都没有变。

社区(Win10)报的 5 条。核心是关窗后再也回不来,以及出了问题只给一个 exit code。

  • 跟 storyboard 0.6.6 对齐三个排布行为:DEFAULT_STRETCH 0.3、flat 判据改成 (1+τ)²、
    逐格部分拉伸。实时性没受影响:240 帧全流程 30.5 → 29.1ms(帧预算 83ms/帧),
    而且排布只在判"这张该封了"的时候跑一次,不是每帧。fill 77.3% → 83.8%,箱数 -5%。
  • 画布仍是 800×450,没跟着对齐 42px:本包的 width 同时是采集管道的 rawvideo 尺寸
    (ffmpeg 直接按它出帧),改它要动 scale / region / PNG 三处,收益只有每张 11 token。

Fixed

  • 手动重开悬浮窗之后仍然被"挂断"拦住,只能重启 DSH。hangUp() 改的是本进程内存里的一个
    布尔值,而 bin/see-window.mjs --force 是另一个 node 进程 —— 它只杀/重开 python 窗口,
    碰不到那个值。用户体感就是"窗明明回来了,它还说我把窗关掉了"。
    现在 start() 里会判断:挂断之后 pid 文件被重写过(= 有人主动开过窗)就解除挂断。
    判据刻意不用 windowAlive() —— 残留进程同样为真,那样会把窗重新弹出来(正是以前修过的问题)。
    新增 resumeFromReopen() 纯判据 + 4 条回归。

  • 采集提前退出时看不出原因。以前只有 code !== 0 且 stderr 非空 才算失败,code === 0
    会静默 resolve —— 上层看起来和"正常收工"一模一样。屏幕采集不存在自然播完,所以现在
    code 0 和非 0 一律报错,并且带上运行时长与已收帧数(12fps 下 141s/1686 帧说明是跑着跑着停的,
    不是启动就崩 —— 这正是区分"缺包秒退"和"中途掉线"的关键)。

  • capture-stopped 之后还在连发「有新画面了」,文案里「上一句 say N 秒前」的秒数越来越大。
    lastSaidNote() 用 Date.now() 实时算,采集停了还在造新提词,那句当然是过期的。
    现在采集死后只允许第一拍把已经攒好的那张报出去,之后不再产生唤醒(队列里的图仍能 peek 到,不丢东西)。

  • rowOptions 缓存键只编码了 stretch > 0.15 这一个布尔,0.2 和 0.3 共用同一份候选。
    改默认容差时踩到:先算 0.2 再算 0.3,后者拿到的是没有 flat 的候选,
    [3,4,4,4,7,7,7,2,2] 从 1 张装下变成 2 张。改成字符串键(档位|grow|stretch|fillRows)。

三个踩过的坑(都有回归)

  • 静音段 WASAPI loopback 一个字节都不推(实测 3 秒静音 callback 触发 0 次)。
    所以时间码只取每块到达的墙上时间,绝不累加样本数 —— 否则静音 10 秒后一开口,
    字幕整体漂 10 秒。另外不能用阻塞 stream.read():静音时它会一直挂着
    (实测想读 4 秒、实际卡了 19 秒),断句判据根本没机会跑。改成回调 + 队列 + tick()。

  • 立体声必须先混单声道再降采样。reshape(-1, 3).mean() 一步做完会让两个声道
    相位抵消,ASR 只认出"嗯。"。正确顺序 reshape(-1, ch).mean(1) → 3 点箱式平均,
    纯 numpy 就够(不用 scipy / ffmpeg)。

  • 它听得见自己。TTS 念台词 → 音箱出声 → 回环收回去 → 变成"视频里的台词",
    然后模型接着那句话往下聊,停不下来。

    修的时候才发现:quiet-until.json 只有陪看在写(bin/watch.mjs 拿它防两个 SAPI 抢话),
    see-now 从来没写过 —— 也就是说 mic.py 的 tts_busy() 在 see-now 下一直是空转。
    现在在 say 的 due 事件上挂了一个只观察、不出声的订阅者(voice:false,标 true 会顶高
    speechSubscribers(),展示层就会一直等一个不来的 spoken),按字数估一个保守窗口
    (中文 4 字/秒 × 1.5 余量 + 1.2 秒收尾)。这是"闭嘴不听",不是"屏蔽自己" —— 窗口内整段丢弃,
    代价是用户正好在说话、或视频里正好有人说话时,那一段也没了。

    判据还必须按段的时间窗、不能按"落盘那一刻":一句话跨过窗口末尾时,落盘时已经出窗口了,
    但它的前半段仍然是 AI 的声音 —— 实测窗口只剩 0.25 秒时,整段 6 秒被当成视频台词收了进去。

为什么是 pyaudiowpatch

ffmpeg -f dshow 在这台机器上枚举不到"立体声混音"(注册表里有、设备管理器也显示启用,
dshow 就是看不到);soundcard 的 loopback 两个端点、四组采样率恒返回 0;
sounddevice(PortAudio 原生)的 WASAPI host API 里根本没有 loopback 设备
—— 官方 PortAudio 不暴露,只有 pyaudiowpatch 那个 fork 打了补丁。它也不需要虚拟声卡。
缺这个包就静默降级,status 里会说缺什么。

v0.0.7 · 心跳抖一下不再把会话判死

Choose a tag to compare

@SAOS-3624 SAOS-3624 released this 02 Nov 18:07

社区反馈(1 例):「放着放着它自己就结束了,模型说了句『好,先停在这儿』就再没下文」。只有一个人报,本来当个例放着;查下来是真 bug,而且判错一次就回不来。

窗口每秒 touch 一个心跳文件,6 秒过期就算「这窗不能用了」——这是 issue #2 修的,为的是能重开卡死的窗。常驻循环拿同一个判据去回答「用户是不是不看了」,于是:

  • 机器睡一觉醒来、或窗口主线程被拖住几秒 → 心跳过期 → 循环第一拍就 stop('window-closed')
  • 这个停因会落一条挂断说明「用户关掉了悬浮窗……不要再调 see_now」,还会 hangUp()
  • hangUp() 之后 watch 一律被硬闸拦 —— 这个会话只能重开

修了什么

  1. 拆出一个只看进程的判据 —— 新增 windowPidAlive()(process.kill(pid, 0),不碰心跳)。windowAlive() 复用它,行为不变,仍然负责「这窗还能不能用」。
  2. 「用户不看了」另用一套判据 —— 新增纯函数 windowVerdict():进程没了 = closed(照旧停 + 挂断,尊重「关窗就别再弹」);进程还在、只是心跳断了 = stalled,连续 WINDOW_GONE_TICKS(3 拍 = 6 秒)才停,而且不挂断,下一条 watch 能自己把窗重开起来。
  3. 停因可区分 —— see_now({action:"status"}) 里的 循环:已停(…) 现在能分出 window-closed 和 window-stalled。

3 拍这个宽限不只是把误判推迟 6 秒:醒来后 Tk 主循环 1 秒内就会重写心跳,第二拍 windowAlive() 就真了、计数归零,循环活着。只有窗口真卡死(6 秒后仍不跳)才走 stalled。

验证

  • 复现:pid 指向活着的进程、只把心跳文件的 mtime 往前拨 8 秒 → 老代码第一拍就 window-closed
  • 新增 test/heartbeat.test.mjs 6 条回归(含「心跳过期不该在第一拍就停、也不该 hangUp」),全量 102 条全过
  • 真实关窗的反应时间没变:进程退出 → pid 没了 → 还是第一拍就判 closed

升级:npm i dsh-see-now@0.0.7

v0.0.3 · 修掉文件回放的无限重播

Choose a tag to compare

@SAOS-3624 SAOS-3624 released this 01 Nov 15:48

社区反馈(B 站评论):「文件回放有 bug:回放会在 24 秒里解 1955 帧、攒下一堆唤醒,然后一直弹有新画面——关了悬浮窗也停不干净,得卸载才行」。查下来是三处共同根因,都在 watch 的「采集停了就自动重启」上。

修了什么

  1. 文件放完不再自动重播 —— 以前 watch 看到采集停止就用 prevSrc 重启,而对文件源来说重启就是从头再放一遍,于是无限循环。现在文件放完记为一次性来源,返回 file-ended,除非你显式再传 source / path。
  2. 关窗变成硬闸 —— 原来 onStop 只调了 stop(),下一条 watch 会把采集重启并把悬浮窗重新开起来,用户体感是「关掉又弹出来,只能卸载」。现在走 hangUp(),只有显式指定来源才解除。
  3. 常驻循环不再被 pending 吊住 —— 原来要 !alive && !pending 才收工,一次塞进去几十张图就永远取不完。现在采集停掉后最多再给两拍播报,然后收工;非实时的文件回放干脆不起常驻循环。

验证

  • 新增 guardReason() 纯函数(三条规矩一眼可读)+ 3 条回归测试,共 82 条全过
  • 端到端:3 秒文件 → 播完 → 再调 watch 直接返回 file-ended,确认没有重播

升级:npm i dsh-see-now@0.0.3

v0.0.2 · 首个公开版本

Choose a tag to compare

@SAOS-3624 SAOS-3624 released this 01 Nov 13:33

让 AI 实时看见你的屏幕 —— 但只传「画面变了」的那几帧。

它解决什么

定时截屏喂给 AI,要么贵(一分钟 30 张重复图),要么瞎(30 秒一张)。这个插件先给每帧打一个 0–255 的变化分,只留「突然换了一张」和「一直在动、攒够了量」的帧,再把几帧拼进同一张图。

这一版有什么

  • 单根 rawvideo 管道采集:ddagrab 0.28 核 / gdigrab 0.45 核(12fps → 800×450)
  • 两条判据:切点(12 分地板 + 2.5× 尖峰 + 0.4s 不应期)与积分(固定 144 阈值,不随画面涨)
  • 档位 + DP 装箱:格子边长按 1/k,自动铺满画布
  • 三档节奏 pace:calm(20分/30s) / normal(12分/15s) / dense(7分/8s) —— 模型可以真的调
  • 逐场景模式 scene:一张图只装一个画面、铺满整张
  • 调试窗:实时看抓帧与拼图,不在发布包里
  • 79 条测试

实测(本机 2560×1600 / i9-12900HX)

内容 变化分
静止桌面 0.00
光标移动 0.7
色调接近的照片切换 16~18
滚网页 / 切窗口 30~42
完全不同的照片切换 46~63

许可

PolyForm Noncommercial 1.0.0 —— 个人、学习、非商业免费;商用需授权(见 COMMERCIAL.md,个人 ¥128/年,小团队 ¥999/年)。

examples/floating-chat/ 源自同作者的 dsh-watch-together,沿用 Apache-2.0。