メインコンテンツへスキップ
C

dsh-live-speak

chaowei08/dsh-live-speak

Reads assistant replies aloud in DeepSeek Harness while they stream, sentence by sentence, with a sidebar player that follows the speech line by line.

インストール

dsh plugin --profile web add github:chaowei08/dsh-live-speak

README

dsh-live-speak

让 DeepSeek Harness 把回复念出来。

给 DSH 桌面端的语音播报插件:模型边生成,它边念;不用等整段写完,也不用一直盯着屏幕。

语音用高保真神经语音合成(默认中文女声「晓晓」,语速 +5%),自带播放器,可随时暂停 / 恢复 / 停止,不依赖桌宠、不依赖 DSH 的语音开关。


特性

  • 边生成边念:第一句出现就开始出声,长回复的等待感基本被抹平
  • 左侧导航栏独占一行:点箭头就在这一行下面展开卡片,不浮层、不抢地方(也可切「顶栏」形态)
  • 歌词式三排:上一句 / 当前句 / 下一句,当前句加粗左滚对齐,配蓝色渐变进度条
  • 播放控制即时生效:暂停停在当前字,继续从原位念,停止连未念的一起清掉
  • 写代码的回合自动安静:只在开头念一两句,中间静音,结束念一次结果段
  • 静默久了先响一声:静默超过 45 秒、且确实还有内容要念,开口前先响两声提示音
  • 模型反问会被念出来:选项卡里的问题单独取出播报,提醒你去回答
  • 朗读前会清洗:代码块、表格、裸链接、纯符号行不念;文件名换成人话(网页、脚本、样式表…);同类文件列表折叠成「3 个网页」
  • 跟随宿主:主题、字体、字号、界面语言跟着 DSH 通用设置走
  • 十个可调项:音色、语速、音量、单次字数、打断策略、形态、默认展开等

安装

已发布到 npm,源码在 GitHub,并已提交插件市场收录。三条路径任选一条:

① 插件市场(最省事)

打开 DSH 左侧「插件市场」,搜 dsh-live-speak,点安装,重启 DSH。

市场收录需要审核,条目上线前可能搜不到。搜不到就用 ②。

② 填包名(npm)

DSH 左侧「插件」页 →「安装插件」→「包名或地址」一栏填:

dsh-live-speak

点「安装」,它会先检查再确认。装完重启 DSH。

③ 填 GitHub 仓库地址

同一栏填 https://github.com/<owner>/dsh-live-speak。这条要求本机能直接访问 GitHub,或已配好代理。

开发调试时也可以填本地目录路径或下载好的 .tgz 文件路径,DSH 的安装框同样支持。

装好的判据:左侧导航栏出现「语音播报」这一行。


快速上手

  1. 听第一段:随便问一个问题,留意导航栏那一行变成播放态、卡片开始走字
  2. 试暂停:点暂停,声音停在当前这个字;再点继续,从原位接着念
  3. 试静音:点 🔊,图标转红,本轮不再出声

改语速 / 音色:设置面板 → 语音,改完立刻生效,不用重启。


配置项

项默认说明
自动播报开关掉后只有手动点播放才念
音色晓晓下拉选择,即时生效
语速+5%合成语速
音量+0%合成音量(与播放音量分开)
一次念多少字120实时模式攒到这个字数送一次合成
播放音量0.9本机实际听到的音量
新内容到了打断旧的开关掉就排队念,一条不落
写代码回合静音开只在开头和结尾说话
形态左侧导航栏可选顶栏
卡片默认展开关打开后进入即展开

配置文件在 %USERPROFILE%\.dsh\dsh-live-speak\settings.json,日志在同目录 log.txt。


使用要求与限制

  • Windows 上的 DSH 桌面端
  • 需要联网:语音合成在云端完成,断网或网络策略拦截就没有声音
  • 不要和 dsh-big-fish-speak 同时开自动播报,两个都会念,会重复
  • 首次出声有 2~5 秒准备时间,属正常
  • 文件名统一念成名词(a.html 与 b.html 听不出区别);前文已报过数量的同类列表会整段省略
  • 45 秒提示音只在真的静久了才响,短回合不响

反馈

  • GitHub Issues:仓库的 Issues 页,附复现步骤
  • npm 包页:dsh-live-speak 包页面留言
  • 插件市场:插件条目下反馈

报问题时带上:在做什么任务、念错/没念的是哪一句(截图最好)、DSH 版本与所用模型、log.txt。


许可

MIT。


开发记录与内部说明(面向维护者,普通用户可跳过)

下面是从开发过程里积累的设计说明、逐版本改动与踩坑记录,保留下来供维护和排查用。 面向使用者的介绍、安装与配置请见上面的正文部分。

dsh-live-speak

用高保真神经语音(默认 zh-CN-XiaoxiaoNeural 晓晓)念模型输出,边生成边念,自己合成、自己播放,可随时暂停 / 恢复 / 停止,完全不依赖桌宠。

两个关键点

实时播报:不等整段回复写完。DSH 的逐字增量是页面侧的 assistant/live-chunk(宿主事件里没有 delta),插件在页面订阅它,边收边按标点攒句,凑够一句立刻送宿主合成,合成好就播。长回复时第一句往往比整段落地早十几秒出声。

真控制:音频是普通 HTTP 资源,页面用 Audio 播放 —— 暂停就是 pause(),停止就是 pause() + 丢队列,是真正的即时控制。

为什么单独做一个

dsh-big-fish-speak 是把文字丢给 dsh-pet 的 /voice/speak,由桌宠自己念。这样一来:

  • 播放不在插件手里,插件没法让声音立刻停;
  • 所谓"关闭"只能把桌宠音量设成 0,播报流程照走,声音是被"藏"起来的。

本插件把音频当成普通 HTTP 资源交给页面,页面用 Audio 对象播放 —— 暂停就是 pause(),停止就是 pause() + 丢队列,是真正的即时控制。

装

源码目录直接 link 进 desktop profile(和 dsh-big-fish-speak 一样):

# %USERPROFILE%\.dsh\profiles\desktop\package.json 里加两处
#   dependencies: "dsh-live-speak": "link:<插件目录>"
#   dsh.profile.bundles: 追加 "dsh-live-speak"

改完重启 DeepSeek Harness 生效。源码改动同样需要重启。

用

播放条在顶栏形态下常驻;在侧栏形态下则是左侧导航里独占一行「语音播报」。平时显示「待播 · 晓晓」。播放键只有真有东西要播(playing / loading / paused,或队列不为空)时才是品牌蓝实心块;空闲时退回普通灰图标,不会常驻一块蓝。

  • 播放 / 暂停:当前这一段立刻停住,再点继续从原位置接着念
  • 方块按钮:停止,并把还没念的段落一起清掉
  • 跳过按钮:只丢掉当前这一段,马上念下一段
  • 喇叭按钮:静音开关。默认有声音,点一下当前这段立刻静默(audio.volume 写 0),图标变成带叉喇叭;再点回到原音量。它不改设置里的「播放音量」,所以取消静音能准确复原
  • ⌄ / ⌃:侧栏行的展开箭头、展开卡片里的收起箭头,同一个 Chevron 图标
  • ×(只在顶栏有):停止并收起播放条
  • 进度条:展开卡片和顶栏有,侧栏行只有 16px 高放不下

试听不在这排按钮里(这个位置让给了静音),在设置页「Edge 语音播报」分区的测试按钮,或 /edge test。

按钮的交互反馈(三处同一套)
  • 默认:次级灰 #666,无填充底。
  • 有内容可播 / 正在播:图标变品牌蓝 #007aff,仍然没有蓝块(原来是整颗按钮铺一块蓝底,被用户点名去掉)。
  • 悬停:叠一层 --dsw-alias-interactive-bg-hover 淡灰底,离开就消失。
  • 按下(:active):图标瞬间变蓝,松开回原色。这是点击的即时反馈,不改变任何状态。
  • 静音中:喇叭键图标转警示红 --dsw-alias-state-error-primary(#ff453a),并同时画上叉。原来只靠"画叉"分辨,颜色不变,看上去像点了没反应——所以补了颜色。

带 data-muted 的按钮会吃到这条红色规则;三处(侧栏行、展开卡片、顶栏)的喇叭键都渲染了这个属性。

顶栏那颗喇叭键单独踩过一个坑:内联形态有一条 .dshLiveDock[data-inline="true"] .dshLiveSpeak{...;background:0 0} 用来压掉常驻底块,它的特异性(两 class + 一属性)高于 .dshLiveSpeak:hover(一 class + 一伪类),于是把悬停灰块一起压没了 —— 表现就是"顶栏上其它三颗悬停都有灰块,只有喇叭没有"。修法不是加 !important,而是按同样特异性补一条 [data-inline="true"] .dshLiveSpeak:hover。

三处按钮为什么一样大

侧栏行、展开卡片、顶栏播放条是三个不同的容器,但按钮按同一套几何画:方块 16px、圆角 4px、图标 13px(图标占方块的 0.8)。14px 的正文字高约 16px,所以方块和文字等高,不会"块比字大"。

两个细节值得记:

  • 图标要按包围盒居中,不是按视觉重心猜。播放三角用 M5.5 5v14l13-7z(包围盒 5.5→18.5,中心正好 24 网格的 12);早先用 M7 4.5 起笔,在 16px 方块里整体偏右约 0.8px,肉眼很别扭。
  • 描线图标在小尺寸上比实心显重。× 的 strokeWidth 从 2.4 压到 2,并在顶栏里单独收到 12px,才和实心的 ▶ ■ ⏭ 一个重量;停止方块也从 14/24 收到 12/24,否则实心方块比三角沉一档。
展开卡片里的歌词区

卡片标题下面那块是三行歌词,像 LRC 一样跟着音频走,高度写死 60px(19px × 3 + 2px 间距),句子长短不同也不上下跳。

必须先讲清一件事:合成单位 ≠ 显示单位。 这两件事曾经被混在一起,走了弯路(把音频也按句切碎,结果段间停顿、首句要等好几秒)。现在的分工是:

  • 合成:chunkText 仍然按 targetChars(默认 120 字)攒块。一块就是一次 Edge 请求、一个音频文件,播报从头到尾连续,不会因为句号停顿等你把下一句滚上来。
  • 显示:块内用 splitLyricLines 切成歌词行。它比句级更细:。!?!?…;;、换行、逗号、冒号都断行,括号/引号/书名号里的一律不断(同一套配对栈)。顿号(、)不切——切了会把「这里有火锅、」「有茶馆。」拆成两行,列举词被腰斩,读起来很怪。这样一行通常 8~14 字,一行正好放得下。

时间轴来自 Edge 自己。@andresaya/edge-tts 在 metadataoptions 里同时开了 sentenceBoundaryEnabled 和 wordBoundaryEnabled,generate() 返回的 SynthesisResult 里带 sentenceBoundaries(每句的 offset / duration,单位是 100ns tick)和 wordBoundaries。Edge 只在句末标点处切句,所以服务端句数永远比我们的歌词行少,buildCues(lines, result) 因此走两级映射:先把每条服务端句子按字数预算认领它覆盖的那几行,再把该句的真实时长按行字数分下去;两者数量恰好相等时一句对一句,只有完全拿不到时间戳才退化成"按字数比例铺满整段"。实测 4 行歌词落到 0.05s / 3.11s / 5.36s / 7.64s,服务端 3 句的真实区间原样保留,逗号处只是在句内部二次均分。_tmp/cue-test.mjs 是这套映射的回归脚本,改切分规则后跑一次就能看到每行的起止秒数。

客户端 audio.ontimeupdate 里按 currentTime 找出当前 cue,只有换句时才 emit(),所以歌词区的推进是跟着声音走的,不是定时器估的。

然后是这块区域本身:

  • 当前句在中间行,品牌蓝高亮;上一句和下一句是灰的,一眼能看出播到哪。
  • 三行统一 12px 固定字号(比侧栏导航标题 14px 小两号),不再按句子长短算字号,也不再自动缩小。12px 是写死的,句子长短不影响字高,所以三行不会上下跳。
  • 当前句永远只有一行:以前长句会被压成两行、缩字,现在取消。一行装不下的部分(宽度 = 字数 × 12px,可用宽度约 218px,约 18 字以上算长句)改成向左平移滚动——文字从右侧慢慢退出可视区,念到句子后半段时后半句正好在框内。
  • 滚动节奏跟着这一句的播报时长:cueDur = 下一句的起点 - 这一句的起点(末句用 state.duration),速度按 45px/s 折算出理论周期 cycle = 位移量 / 45;理论周期比这句的播报时长还长时,压缩到 max(2s, cueDur × 0.95),也就是念多久滚多久,念完正好停在句尾。
  • 位移不靠 CSS 过渡,靠 requestAnimationFrame 逐帧写 translate。这里踩过三条死路,都不再用了:① CSS 变量(var(--lyricShift))进 @keyframes → 一渲染就到终点;② 内联 ref 回调里设 transition → 播放中每秒多次重渲染会拆挂重挂,文字被反复拉回起点;③ useRef + useEffect 里设 transition → 宿主环境里同样一步跳到终点。现在 effect 依赖 [lyricPlanKey](只有换句才重跑,同一句内 progress 的 emit 不重跑),用 performance.now() 自己算进度 p 并写 el.style.translate,p >= 1 停住。
  • 不再用"同一句排两遍 + infinite 跑马灯"。那种做法回绕那一刻必然出现"左半截是句尾、右半截是句首"两段文字同框(见 1.0.145 实测),所以只保留一份文本。到位后停在句尾,不回头循环。
  • 换句时整块重新挂载(key = "ly" + 段id + "-" + 句序号),走 dshLiveLyricIn 的 9px 上浮淡入,就是"歌词往上滚"的观感。逐字高亮没做,wordBoundaries 已经拿得到,要加随时能加。
  • 上一句:块内取 cues[idx-1];块内第一句时取 manager.prevText,而 prevText 存的是上一块的最后一句(playItem 换块时算的),不是整块 120 字的 preview。
  • 下一句:块内取 cues[idx+1];块内最后一句时取后端下发的 nextText 的第一句(firstSentence()),那是下一块(哪怕是还在合成的块)的开头。所以段与段之间第三排也不会空。
  • 长句(约 18 字以上)时上一句/下一句照常显示,不再让位——当前句固定一行,三行高度够用;只把当前句改成上面说的单行左滚。
  • 空态(还没开始播)只显示「没有待播内容」那一句提示,不把上一轮的残句挂在上头。
  • onended 里故意不清 current,否则每段之间卡片会闪一下空白;stop 才把 prevText / nextText 清掉。
顶栏那一份为什么更"素"

顶栏内联的位置是宿主 header.actions 里一小段,宽度就 300px 上下。所以那一份和侧栏行、卡片长得不一样,是有意的:

  • 只显示状态词(「等待播报」「播报中」…),不拼「· 晓晓」。拼上就变 9 个字,82px 的槽装不下,会被省略号切成「等待播报 · 晓…」。声线名在展开卡片里看。
  • 没有 ×(停止并收起)。顶栏是常驻入口,× 收掉之后就没有入口能把它点回来,只能去设置页切形态;停止的职责由 ■ 承担。× 只保留在浮卡形态。
  • 正文、进度条在顶栏那份里都是隐藏的(CSS [data-inline="true"] 里 display:none),那里只放"状态 + 四颗按钮"。

自动播报的开关在设置 → Edge 语音播报 → 播报行为里,或者用 /edge on|off。

播放条位置可选,/edge dock <位置> 立刻生效(不用重启):

位置形态
top默认。直接嵌进会话标题栏那一行,和标题、工具按钮并排,不遮挡任何内容
sidebar左侧导航栏里独占一行「语音播报」,行内就是播放条;行最右有展开箭头

top 是真正的槽位嵌入(挂 conversation.session.header.actions),不是 fixed 定位,所以窗口缩放、侧栏折叠都不会错位。

两种形态怎么互换(同一时刻只存在一份)

在这个插件里,"切换位置"不是移动同一个元素,而是两份独立渲染、按设置互斥。顺序是固定的循环:

  1. 初始默认「顶栏」。
  2. 选「左侧导航栏」→ 顶栏那一份 HeaderCluster 整体 return null 消失;侧栏那一行出现(0.14s 淡入)。
  3. 选「顶栏」→ 侧栏那一行退出,顶栏回来。
  4. 设置页点选即时生效:先本地合并 settings 并广播重渲染,再异步 POST 落盘。只 POST 的话界面要等下一次 600ms 轮询,看起来像"点了不生效"。

两处必须按下面的写法,否则会踩坑:

  • 顶栏那一份用 return null 退出是安全的,因为它挂在 conversation.session.header.actions,宿主每次渲染都会重新调它。
  • 侧栏那一行绝对不能用 return null 退出。宿主是在挂载时把 sidebar.panellist 这一行插进菜单的;组件一旦返回 null,那一行就被当成空行抹掉,之后再返回内容也不会插回来(表现就是"顶栏条消失了、侧栏却什么都没有")。正确做法:内容始终渲染,用 useEffect 给宿主那一行 <button>(bar.closest("button"))设 display:none。display:none 完全不占位,后面的「自动化任务」「更多」「工作区」自然上提,不留空白;不能用 visibility:hidden 或零高度,那会留一条空行、下面的项也不上提。
  • 切走顶栏时顺手把就地展开的卡片收掉(rowExpanded = false),否则它会以 position:fixed 挂在原地。
侧栏形态:导航行 + 就地展开

侧栏这一行是用 sidebar.panellist 槽位真占位注册的(id 必须与 key 一致),所以它和「插件」「插件市场」「自动化任务」是同一序列里的一个导航项,不是浮在上面的条。行内从左到右是:状态点、语音播报、▶/⏸、■、⏭、最右的展开箭头。整行的指针事件都被拦掉,点它不会切主区域;也因此没有注册 main 面板。

点箭头展开后,那个位置会直接长成一张卡片(● 状态 · 声线、×、两行文本预览、进度条、34px 蓝播放键、■、⏭、"还有 N 段待播"、🔊),下方的「自动化任务」「更多」「工作区」整体被推开。

实现上有三点必须按宿主的真实布局来,否则会错位或直接看不见:

  1. panelRow 的高度写死 min-height:36px(padding:7px + line-height:22px),所以不能在行内做纵向内嵌展开——会把行自身撑爆、把文字和按钮挤掉。卡片只能用 fixed 定位盖在行上。

  2. panelRow 有一圈 border-inline: var(--dsh-sidebar-paint-inset) solid transparent,灰底画在 padding-box 上。所以卡片的 left 要加这个 inset、width 要减两个 inset、圆角要取 radius − inset,才能和「插件市场」这类选中块的可视边缘完全重合。

  3. visibility 是可继承属性。展开时把宿主那一行设成 hidden 以藏掉行内的文字和按键,卡片是它的子节点、会被一起隐藏——卡片必须显式 visibility:visible 才能盖在行位上显示。藏行之外还要给行补 margin-bottom = 卡片高 − 36,下方菜单才会被真正推开(panelList 的 gap 是 2px,行高 36px)。

  4. 位置属性交给 React 受控。卡片坐标写在 cardStyle 对象里,React 每轮渲染都会重设该属性;在 useEffect 里事后写 cardEl.style.left = ... 会被下一轮渲染打回旧值。正确做法是把测量结果写回模块变量,让 cardStyle 自己算,值变了再触发一次渲染跟上。这一条是"数值明明算对了、渲染出来却差几像素"的根因。

展开时的底色取自 --dsw-alias-interactive-bg-hover(和 panelRow:hover / panelActive 同一个变量),无边框、无投影。收起点卡片右上角 ×。

侧栏形态下不渲染任何浮条:右下角浮动条、以及早前贴在「更多」下面的那条都已经去掉,同一时间只有这一行。

展开卡片遇到宿主遮罩要收起

卡片是 fixed 定位、走自己的堆叠顺序,而宿主打开设置页 / 对话框时会铺一层全屏遮罩(div.<hash>_overlay,z-index:1000,尺寸等于整个内容区)。两者叠加的结果是:卡片浮在遮罩之上、还带着自己的投影,里面的 ⌃ 和 🔊 依然可以点——用户看到的就是"播放条压在设置页上"。

修法不是调 z-index(宿主那层的值是它内部定的),而是认这层遮罩,认到就把卡片收起来:

  • hostOverlayOpen():扫 [class*="overlay"],取 fixed/absolute、z-index ≥ 100、宽高都 ≥ 内容区 60% 且未隐藏的那一个。宿主那层是 AnXs9G_overlay(哈希前缀随版本变,所以只匹配后缀 overlay)。
  • watchHostOverlay():300ms 轮询,连续两次采样一致才切换(遮罩有淡入淡出,单次采样会让卡片闪进闪出),变化时改模块级 veilActive 并 emit() 广播重渲染。
  • 卡片渲染条件带上 veilActive !== true;measure 那个 effect 的依赖里也加 veilActive,这样卡片收起时 cleanup 会把宿主行的 visibility 和 margin-bottom 复位,下方菜单不会留一块空白。rowExpanded 本身不动,遮罩一关卡片自动回到展开态。

判断"有没有东西盖住侧栏"不能用 elementFromPoint:卡片正好盖在宿主行上,行中心点命中的永远是卡片自己的标题,遮罩在不在上面根本测不出来(这条弯路走过一版,日志里 hit 一直是 SPAN.dshLiveCardTitle)。

两种形态各自渲染在哪(踩过的大坑)

设置页「播放条位置」二选一(浮动那一项已按需求取消),两种形态分别由两个组件渲染,按 settings.dock 互斥:

形态渲染方式槽位 / 位置
顶栏(top)HeaderCluster → Dock{mode:"top"} 内联横条conversation.session.header.actions,会话标题栏右侧
侧栏(sidebar)SidebarRow 行内播放条 + 就地展开卡片sidebar.panellist 导航行,order: 100(排在「自动化任务」之后、「更多」之前)

这里有两个必须记住的结论:

  1. "槽不产 DOM"的表象,真因是组件自己抛异常。 Dock 里曾有一处 sidebarCard 变量只被读取、从未定义,任何走到 Dock 的槽在渲染时都抛 ReferenceError,React 把整棵子树卸载,表现为"组件被调用、DOM 一条也没有"。修掉后,shell.overlay 与 composer.dock 其实都能出内容。排查这类"调用有、DOM 无"时,先怀疑组件内部抛错,而不是先怀疑槽位机制。
  2. sidebar.panellist 的行是宿主在挂载时插入的,组件返回 null 之后不会回来。 所以侧栏形态的"退出"不能靠 return null,要靠隐藏宿主行(display:none)。详见上一节。
  3. 导航行的位置由 order 决定,而宿主「自动化任务」的 order 是个很大的值。 实测 order 取 1 / 2 / 3 / 4 / 6 时都落在「插件市场」与「自动化任务」之间、位置完全不动;要跨到「自动化任务」后面必须给到 100(此时仍在「更多」之前)。插件市场的 order 是 1,都在同一个 sidebar.panellist 段里。

顶栏形态不复用浮卡版式:Dock 的竖排版式(写死 328px 宽、竖排、带投影)塞进一行会被压扁、按钮重叠;mode:"top" 时改用 flex-direction:row + 内容自适应宽度,与宿主标题栏右侧按钮并排。

图标:与宿主同一套几何

四处图标(侧栏行、展开卡片标题、设置页分区图标、插件 logo icon.svg)共用一份几何,由 client.js 顶部的 GLYPH_STYLE 一个开关切换(bars / tile / line / grid9 / pinwheel)。

当前是 bars(四根块状声波,播报中跳动 + 整块呼吸):

项值说明
柱宽 / 圆角2.0 / rx 1.0半圆头,12 物理像素下仍是"条"不是"点"
步距3.4(间隙 1.4)四根清楚分开
左右边界1.96 → 14.16与宿主「插件市场」九格同边界
底部14.16与九格同一底边
高度6.4 / 11.4 / 8.4 / 4.4下沿对齐

为什么边界要抄九格:宿主图标槽固定 16 CSS px(本机 0.75 缩放 = 12 物理像素)。图形在 viewBox 0 0 16 16 里占多少,就等于它在屏幕上占多少。九格占 1.96→14.16(约 76%),如果我们的图形铺到 0.8→15.2(90%),缩进同一个槽后就会比邻居宽一截、贴边更紧——这就是"我们的图标看着不一样"的真正来源,不是尺寸、不是线宽。实测两边槽都是 16×16、left 都是 20、墨量差 8% 以内。

两条踩过的坑:

  1. 图标柱子的类名不能叫 dshLiveBar。那个类名同时是播放进度条的样式(height:3px; border-radius:999px),CSS 优先级高于 SVG 的 height 属性,四根柱会被强制压成 3px 高、圆角一挤就成了"四个点"。必须用独立类名 dshLiveGlyphBar。
  2. SVG 的 fill 属性不解析 var(),想用主题变量必须写 style={{fill:"var(--dsw-alias-...)"}}。

icon.svg 是插件市场列表里展示的 logo:同一份几何、512×512 画布、固定品牌蓝 #007aff;运行时那三处走 currentColor(浅色主题深、深色主题浅),所以文件和界面看着一致但配色各自正确。

后台设置面板

DSH 的设置 → Edge 语音播报里有完整面板,不用记命令(导航行图标照插件图标的形状画,只用 currentColor,所以深浅主题和第三方皮肤都自动跟随):

  • 播报行为:自动播报、实时播报、新回复是否打断旧内容、写代码时只念开头和结果(codeQuiet)、侧栏播放条默认展开(dockExpanded)
  • 播放条位置(二选一):两张卡片带示意图,同一时间只用一种位置
  • 声音:声线下拉(18 个内置声线,含粤语、台湾国语、英式/美式英语与能念中文的 Multilingual)、语速滑块(-50% ~ +100%)、播放音量滑块、试听按钮

改动即时写盘(settings.json),刷新界面也保留。

侧栏播放条默认展开(dockExpanded):只在「左侧导航栏」形态有用。打开后侧栏那一行一开始就是展开的卡片,不用每次点行尾箭头;手动点过箭头(或用卡片右上角 ⌃ 收起、点 × 关掉)之后以手动为准,设置不再覆盖;选了顶栏形态则没有任何效果。设置面板里改完立刻生效,关掉设置页那一瞬间卡片就在。

它不是"插进宿主那一行里",是"盖在那一行的位置 + 把下面推开":宿主给侧栏这一行写死 36px 高,行内绝对定位会被 overflow 裁掉(试过,卡片直接被切没),所以卡片只能 position:fixed。要让它在观感上等同于行内展开,得同时做三件事——把卡片对齐到那一行的矩形(top/left/width/圆角 都按实测值写)、把那行本体 visibility:hidden、再给那行补 margin-bottom = 卡片实测高 - 36 把「更多 / 工作区」整体下推。三件事少做一件,卡片就会浮在上面盖住「更多」。

1.0.180~1.0.182 补的三处(重启后默认展开,卡片盖住「更多」的偶发):

  1. 默认展开这条路径上,effect 第一次跑的时候卡片还没渲染出来 —— 原来在 effect 开头就把 .dshLiveRowCard 查好缓存在闭包里,结果一直是 null,宿主行没被隐藏、菜单也没被推开。改成在 measure() 内部每次现查,并在一帧后补量一次。
  2. 设置是轮询回来的:在渲染期直接改模块变量 rowExpanded 只是改了个变量,界面不会重画(重启后看起来就是"开关没生效")。改成走 useEffect + force()。
  3. 侧栏列表一旦滚动,固定定位的卡片会跟丢那一行:给列表容器加了 scroll 监听重量。

排查这类问题最快的入口是 /debug 里的 row-want(设置值有没有到客户端、算出的目标状态)与 row-geom(行与卡片的实测矩形、隐藏与下推是否生效)。

配色跟随主题:播放条和设置面板的所有颜色都取自 DSH 的设计变量(--dsw-alias-bg-layer-2、--dsw-alias-label-primary、--dsw-alias-border-l2、--dsw-alias-button-info-fill 等),没有硬编码。所以切深色/浅色、换第三方主题皮肤时,播放条会一起变,不需要另外适配。

命令行:

命令作用
/edge 或 /edge status看声线、语速、音量、队列状态
/edge on / /edge off开 / 关自动播报(写盘,重启保留)
/edge live on / /edge live off开 / 关实时播报(关掉就退回"整段写完再念")
/edge dock top播放条停靠位置:top(顶栏)/ sidebar(左侧导航栏)
/edge test用当前声线试听一句
/edge list列出内置声线
/edge voice zh-CN-YunxiNeural换声线(中文文案必须配中文声线)
/edge rate +8%语速,范围 -100% ~ +200%
/edge play 80%页面播放音量
/edge clear清空未播队列
/edge say 随便一句话手动念一段(会打断正在念的内容)
/live-speak 随便一句话同上,独立命令写法,少敲一层

手动念白不叫 /speak、/say、/read 这类通用词:/speak 已被 dsh-big-fish-speak 注册,通用词也容易被其他播报插件占掉,而命令重名会让注册失败。统一挂在插件自己的名字上:/edge speak(子命令)、/edge say(子命令别名)、/live-speak(独立命令),三条入口互不冲突。

跟随 DSH 通用设置

「通用设置」里改任何一项,插件这边都不该显得突兀。摸清宿主真实机制后,跟随 / 不跟随的边界是这样:

通用设置项宿主的实现插件怎么跟
外观 浅色 / 深色 / 跟随系统ThemePresenter 在 root 设 color-scheme,在 body 挂 data-ds-dark-theme,并把整套 --dsw-* token 写成 body 内联变量全部颜色走 var(--dsw-alias-*) 等设计变量,自动跟随
正文字体body 设 --dsh-font-family-text,--dsw-font-family 取自它,body{font-family:var(--dsw-font-family, …)}浮层写 font-family:var(--dsw-font-family,inherit);所有 button / select 显式 font:inherit —— button 默认是 13.33px Arial、不继承字体,这是最容易露馅的一处
代码字体 / 代码字号 / 终端字体 / 终端字号--dsh-font-family-code、--dsh-code-font-size、--dsh-font-family-terminal、--dsh-terminal-font-size插件没有代码块与终端区域,不涉及
字号大小(仅影响会话内容)body 设 --dsh-content-font-size(默认 14px),派生 --dsh-content-font-delta = 设定值 − 14px;消费方写 font-size:var(--dsh-content-font-size,14px)、line-height:calc(22px + var(--dsh-content-font-delta))、图标 calc(16px + var(--dsh-content-font-delta))不跟。宿主侧栏 .wtg9ga_root 自己就是写死 font-size:14px、不随该设置变化;我们和「插件」「插件市场」在同一序列里,跟着缩放反而比邻居大一号。实测确认:调大字号后侧栏三个菜单项全部不动,只有会话正文变大
文字粗细宿主菜单项、设置页各行的标题都是 font-weight:400插件设置页的行标题原本写的是 font-weight:500:中文在 500 下没有真实字重、由浏览器合成加粗,字形和宿主明显不是一路(那个"看着不像同一个字体"的老问题一半出在这里)。现在统一 400,层级靠字号和颜色区分,不加粗
系统「减少动态」宿主用 @media (prefers-reduced-motion: …) 关闭动效浮层入场动画与进度条过渡同样受该媒体查询约束
语言(8 语种)宿主整包 i18n,ctx.locale.getSnapshot().active 给当前语言 id(BCP47),ctx.locale.subscribe(cb) 在切换时回调已跟随:内置 中文 / English / 日本語 三套文案,其余语种(Español / Français / Deutsch / Русский / Português)回落英文。切换语言后会重新渲染一遍界面;声线下拉另带英文名,非中文界面显示英文

另有两点容易踩到:

  • 暂停态圆点用 --dsw-static-amber-500(#f59e0b)。宿主没有 --dsw-alias-state-warning-primary 这个变量,之前一直靠 #ff9f0a 硬编码兜底。
  • 圆角一律用 --dsw-radius-*。宿主还全局启用了 corner-shape: superellipse(1.5),我们的 border-radius 会自动吃到同一种圆角形状,不需要额外声明。
进度条的分母在后端

卡片里的进度条按 progress / duration 算宽度。duration 原本只等 audio.duration,但 Edge TTS 的 mp3 在开始播放前 audio.duration 常是 Infinity(要播上几秒才变成真实秒数),于是分母为 0、百分比恒为 0——播报中进度条一直是空的,看起来就像"进度条坏了"。

现在的做法:宿主合成时就把每段时长算好了(/queue 的 ready[].duration),前端在队列里保存它、切段时先拿它当分母,等 audio.duration 变成有限值再覆盖。这样从第一帧起进度条就有分母,实测 pct 会按 15→27→39→…→100 正常推进。

怎么工作的

实时路径(默认):

页面 assistant/live-chunk(逐字增量,宿主事件里没有)
        │  攒句:首句 ≥14 字就切,之后按句号攒到 ≥36 字
        ▼
POST /plugins/dsh-live-speak/speak {text, append:true, live:true}
        │  append = 同一条回复内追加,不打断
        ▼
   Edge TTS 串行合成(429 / 网络错指数退避,最多 4 次)
        │
        ▼
GET /plugins/dsh-live-speak/audio/<id> → 页面 Audio 播放

整段落地的 assistant/message 会被跳过:30 秒内有过实时上报就认为已经念过,不再重念一遍。

回退路径(/edge live off):

session/event(assistant/message)
        │  清洗 Markdown(代码块、链接、表格转口语)
        ▼
   切段(首段 ≤45 字先出声,之后每段约 120 字)
        │
        ▼
   Edge TTS 串行合成 → 内存 Buffer → 页面 Audio 播放
  • 合成在宿主进程(Node)里跑,不走 Electron IPC,桌宠关不关都不影响。
  • 桌面版页面是 dsh-app://app,/plugins/** 的请求会被转发给宿主 HTTP 服务,所以页面里用相对路径就能取到音频。
  • 新回复的第一句到达时,旧队列直接作废(epoch 变化),页面立刻静音再念新的。想改成"念完再念新的",把设置里的 interruptOnNew 改成 false。
  • 播放条在 conversation.session.header.actions(顶栏)与 sidebar.panellist(侧栏)两个槽里各渲染一份、按设置互斥;落脚容器若是 pointer-events:none,根节点必须自己写 pointer-events:auto,否则看得见却点不动。按钮同时挂 pointerdown 和 click(300ms 去重),避免上层把 click 吃掉。
实时流会断,整段落地的消息只补尾巴

实时路径(assistant/live-chunk)不是一条连续的流:模型写文件/跑工具时,增量里会出现 blockType: "tool-call" 的块,写代码那几十秒里没有一个 text-delta 到达。这里踩过三个坑,1.0.160 一起修:

  1. 块边界不能丢字。原来的 block-start 直接 buffer = "",把上一段攒着但没到 36 字门槛的半句永久丢了("上箭头键:旋转方块"就是这么消失的)。现在进入新块前先 streamCut(true) 把尾巴念掉,再换块。
  2. 块类型只认已知的该跳。blockType 实测有 text / tool-call(代码块走 tool-call 更常见),新的取值随时会出现;用「不等于 text 就跳过」的写法,宿主一加块类型就整段失声。现在是 code/table/chart/image/tool/reason 命中才跳,未知类型当正文念,并把首次出现过的类型打到 /debug 日志(block-type)。
  3. 整段落地只补尾巴,绝不整段重念。assistant/message 会在这条回复结束时带着完整正文再来一次,而实时路径因为第 1、2 条已经念了一半。去重不能用「30 秒内有过实时上报」这种时间窗——写代码慢的时候窗口一过期,整段就被从头念一遍(同一个开场白听两遍,就是这么来的)。现在的判据是文本前缀:liveSentText 累积本条回复实时送出过的原文,speechTail() 把整段消息和它按「可念字符」对齐(空白、标点、符号都忽略,所以 markdown 结构差异不影响匹配),只把没念到的那一截补进队列(interrupt: false,接在后面念)。对不上时保守整段补——宁可多闷一句,不吞内容。

实测(一条"写个俄罗斯方块网页小游戏",正文 517 字、中间夹一次 40 秒的写文件):实时念到一半断流,落地时日志打 补播 assistant/message 尾巴 chars=277(实时已送 240 字),队列从"上箭头键:旋转方块"接着念到结尾,全程没有重复的开场白。

时间窗是错的,改用文本重合度(1.0.163)

上面第 3 条最初还留着一个 LIVE_REPLAY_WINDOW_MS = 180_000:只有落地时刻距离最后一次实时上报不到 3 分钟,才走"补尾巴"。这个时间窗后来删掉了——写代码带写文件实测断流 258 秒(4 分 18 秒),窗口一过期就退化成整段重念,用户听到的就是"第三次把全文又念一遍"。

现在两处都只看文本:

  • liveOverlap(text, sent) 判断这段文本跟已送内容的关系:整段包含在里面 → {replay:true, tail:''}(丢弃);开头 k 个字(k 从 240 往下试到 8)正好接在已送文本末尾 → {replay:true, tail:剩余}(只念新增);完全不重合 → {replay:false}(真的是另一条回复,整段念)。
  • 时间只留在日志里(实时已全播(258s 前…)),不再参与判断。

另一个必须记住的坑:实时流会重放。模型中途调工具之后,宿主会重开一个 attempt、把正文从头再发一遍。客户端 stream.attempt !== data.attemptId 就把它当"新回复",于是 interrupt: true 清空队列、liveSentText 被重置成那一小段,服务端的前缀比对失去依据,整段又被念一遍。所以 /speak 现在每段实时文本都先过一遍 liveOverlap,重放的段落一律丢弃,只把真正新增的送进队列,并且不打断正在念的那句。离线回归在 _tmp\tail-test.cjs 用例 [8]:模拟"先送 93 字 → 写文件 → 从头重放 5 段 → 再接新内容",要求合计送出 = 全文可念字数(250/250),一个重复的字都不许有。

还有一条同源防护:补播的尾巴要并进 liveSentText。宿主对同一条回复偶尔连发两条完整快照,不并进去,第二条就会从同一个断点再补一遍(用户说的"第二次播完又重复了一次全文")。

实测(贪吃蛇,正文 491 字,18:22:33 起): skip assistant/message:实时已全播(75s 前,已送 93 字) → 实时段重放:裁掉重复 ×N → 补播 … 尾巴 chars=260(实时已送 231 字),46 条 cue 去重后无一条出现两次。

设置文件

%USERPROFILE%\.dsh\dsh-live-speak\settings.json

{
  "auto": true,
  "realtime": true,
  "dock": "top",
  "voice": "zh-CN-XiaoxiaoNeural",
  "rate": "+8%",
  "volume": "+0%",
  "targetChars": 120,
  "playbackVolume": 0.9,
  "interruptOnNew": true
}
已知边界
  • 和 dsh-big-fish-speak 不要同时开自动播报:两个插件都盯着 assistant/message,会一前一后各念一遍。用本插件时把 big-fish-speak 的自动播报关掉(或从 profile 的 bundles 里去掉它)。
  • 必须能连外网:Edge TTS 走语音服务的 WebSocket 接口,断网或公司网络拦截 WebSocket 就没声。
  • 首次出声约 2~5 秒:一次合成的握手 + 音频回传的固有延迟。实时模式下这是"从第一句出现到听见声音"的时间,整段文字还在继续生成。
  • 实时模式的句子是切的:为了让声音不断档,后面每段攒到 36 字左右再送合成;句号出现得晚时会等到 90 字在逗号处硬切。想更碎更快就调客户端 streamCut 里的门槛。
  • 语音服务会限流:偶发 429,插件会自动退避重试;连续失败时播放条会显示失败原因,跳过这一段继续念后面。
  • 代码块和表格不念:代码块整块丢弃,表格转成 列1,列2。 的口语行;纯符号的段会被跳过。文件引用(界面上的文件 chip,如 tetris.html)和裸链接也一并剔掉,不念文件名和 URL。
  • 自动播放策略:如果浏览器拦了自动播放,播放条会显示"等待播放",点左侧播放键即可,之后同一会话里的自动播放会被放行。
  • 音频常驻内存,最多 60 段 / 15 分钟,超了自动丢最旧的。
  • 静音只作用于本次运行:muted 是客户端内存状态,不写进 settings.json,所以 DSH 重启后一定恢复成"有声音"。
  • 文件名统一念成名词:a.html / b.html 在语音里都念成"网页",听不出区别;同类并列会折叠成"3 个网页",混类才逐项念(1.0.195)。
  • 前文已报数就整串省掉:说了"5 个页面文件"之后,后面的文件名列举不再念,只剩"创建了 5 个页面文件。"(1.0.195)。
  • 静默不足 45 秒不响提示音:CHIME_MIN_QUIET_MS = 45_000,十几秒的短回合故意不出声;超过 45 秒且还有内容要念,才先响两声短音再开口(1.0.186)。
  • 反问能不能念取决于模型怎么问:走 ask_user_question 选项卡的一定念;只写在正文中部、不含第二人称或"要不要/是否/哪种"这类词的不会补念;修辞问句故意不抓(1.0.186 / 1.0.194)。

对外首发文案见 RELEASE-NOTES.md(功能、用法、反馈渠道,不写内部改动);内部待办、改动明细与踩坑记录见 INTERNAL-BACKLOG.md(打包时排除,不对外)。

排错
  1. 按钮不出现:说明 client bundle 没加载成功,先确认 package.json 里 dsh.client 与 exports["./client"] 都在,且 profile 的 bundles 里有 dsh-live-speak。
  2. 按钮在但没声音:/edge status 看队列是不是 0;/edge test 试一句;再看设置文件里的 auto。
  3. 想彻底停掉:/edge off 之后再在设置里关掉自动播报,或从 profile 的 bundles 里删掉这一项重启。
写代码类回复:每个字只念一遍(1.0.165~1.0.170)

写代码的回复是播报最容易出错的一类:模型还没出字就先有增量、工具调用后宿主重开 attempt 把正文从头再发一遍、代码正文直接摊在回复里。这一串下来,早期版本会播三遍(开场一遍、整段一遍、尾巴再一遍)。

日志确认过的宿主行为:

  • 工具调用(block-type: tool-call)之后,宿主会重开一个 attempt 并从头重发正文;页面只看到 attempt id 变了,会当成新回复(append=false → interrupt:true,并重置已送文本)。
  • 写文件时实时流断流实测 258 秒,所以任何时间窗(30s / 180s)做去重都会失效 —— 时间窗这条死路已经删掉(LIVE_REPLAY_WINDOW_MS 不再存在)。
  • 整段落地的 assistant/message 常常比实时最后一段只晚 73ms,两路几乎同时到。

现在的做法(顺序固定):

  1. 实时段先进 cleanForSpeech() 再对齐 —— 不清洗就会把 padding、background-color:、// 注释 整段念出来(1.0.169 实机出现过 122 条这种 cue)。
  2. liveOverlap(text, sent) 三分支判重放:
    • 整段在已送文本里(长度 ≥20,或短段恰好落在已送文本前半)→ 纯重放,丢弃;
    • 这段的开头在已送文本某处接上、一路延伸到已送末尾 → 只念超出那一截;
    • 开头正好接在已送末尾(k 从 240 往下试到 8)→ 只念新增。
  3. assistant/message 落地时先走 liveOverlap,再退回 speechTail 前缀法;尾巴一律 interrupt:false 接在后面念,补播完把尾巴记进已送文本,同一条回复的第二份快照才不会从同一个断点再补一遍。

清洗侧(cleanForSpeech,针对代码):

  • 围栏代码块整段丢掉,没闭合的围栏也从围栏开头一直丢到结尾;
  • 逐行 looksLikeCodeLine() 过滤:含 ;{}、注释前缀 // /* */、半角 =、name( 调用形、function/const/let/return/import 等关键字、HTML 标签、^[.#] 选择器、属性: 值(含 px/rem/%/#/rgba/flex 等)、对象键 boardWidth: 这种、符号密度 >34% 的行,都不念。

离线回归:

  • node _tmp\tail-test.cjs —— 9 个用例,含 [8] 重放场景(已送 93 字 → 写文件 → 宿主从头重放 5 段 → 再接新内容),要求合计送出 = 全文可念字数(250/250)。
  • node _tmp\code-filter-test.cjs —— 代码行全丢、正常句子全留。

实机结果(1.0.170,俄罗斯方块 + "代码要分开写清楚"):

  • 全程只有一条 interrupt=true(新回复第一段),没有"整段重念";
  • 35 条 cue,Group-Object 查重 0 组重复(1.0.169 同场景是 101 条 / 22 组重复);
  • 收尾 skip assistant/message:实时已全播(0s 前,已送 285 字)。
写代码类回复:只在开头和结尾说话(1.0.174~1.0.184)

写代码的回复有个绕不开的代价:模型先讲思路、再写文件,实测断流 258 秒。逐字念的话,中间那几分钟念的全是"以下是完整的代码:"这类铺垫,真正有用的结尾说明反倒排在最后。

1.0.174 加了设置项 codeQuiet(默认开)——写代码时只念开头和结果段:

  • 判定放在模型输出侧,不放用户提问侧。用户那句"这段报错怎么修""解释一下快速排序"都能沾到代码词,靠提问分类误判率太高;输出侧的硬信号:围栏 ```、正文里成规模的代码行(looksLikeCodeLine)、正文切到代码块(block-type)、以及写文件类工具(tool/call 里 name 命中 write|edit|create|patch|apply|save|update)。
  • 判到就置 codeQuietTurn,此后的实时增量不入队(/speak 直接返回 quiet:true)。已经排进队列的一两句照样念完,不硬切断。
  • assistant/message 落地时只念 resultTail():按句从尾部整句累加,上限 RESULT_TAIL_MAX = 320 字,保证从完整句子开头念,不会半句起手。
  • 关掉这个开关就完全回到 1.0.173 的行为(逐字念 + 落地补尾巴)。

1.0.175 修的是 1.0.174 实机暴露的一个尾巴重复:收尾念完之后,宿主又发来一条稍长的快照,实时对齐把末尾那句当成"还没念的尾巴"补了一遍(日志里「希望你喜欢!」连着两条 cue)。现在用 quietSettledAt 立闸——同一回合结果段念过之后,后续 assistant/message 和 /speak 增量一律不再补播,等 user/message 才复位。

1.0.176 修的是"同一轮被重开":本地模型会先把一轮重试一次,宿主于是又发一条 user/message,turnFresh 重新立起 → 下一段实时内容带 interrupt:true 把正在念的结果段切断,并把同一篇正文从头再念一遍(1.0.175 实机就是这样)。现在对 user/message 做文本指纹判重:同一条用户消息 2 分钟内重复到达,按"同一回合的重试"处理,不复位已送文本、不复位静音闸。

1.0.177 修的是"收尾整段重播":正文本身就短(判定成代码时全文只剩三句 56 字),resultTail 返回的就是整篇,而这三句开场已经念过 → 三句各念两遍(1.0.176 实机 cue id=1/2 与 id=3 文本完全相同)。现在收尾先跟已送文本做一次 liveOverlap:整段念过就不念(日志写「结果段已经在开场念过,不再重播」),只念过前半截就只补后半截。

1.0.178 修的是"一轮念好几遍结果段":本地模型一轮内会重试多次,每试一次就落一条 assistant/message,1.0.177 实机就连续念了 48 字、62 字、313 字三段结果(内容各不相同,但听感就是反复念)。现在 assistant/message 在静音回合里只记下待念的正文、不立即念,等 turn/end(回合真正结束,这是"模型输出完了"的硬信号)再念最后一份的结果段;万一没有 turn/end,25 秒兜底计时也会念掉。

实机(1.0.174,贪吃蛇任务,本地模型):

  • enqueue chars=19/43/40(开头三段共 102 字)→ 进入静音模式:本条回复出现代码(已送 102 字) → 之后零 enqueue;
  • 落地 静音模式收尾:只念结果段 chars=285(全文 358);
  • 缺点照实说:开头那 102 字比设计的"只念首句"多,因为入队的三段是并行排进去的,判定时已经在队列里。

实机(1.0.178,秒表任务):

  • 22:22:24 enqueue chars=19 → 22:22:25 enqueue chars=37 → 22:22:25 进入静音模式:模型开始写文件(tool-call 块);
  • 22:26:07 静音模式:记下结果段待念(全文 77 字),等 turn/end 一并念 → 同毫秒 turn/end → 静音模式收尾(回合结束):只念结果段 chars=20(全文 77) → enqueue chars=20 interrupt=false;
  • 全轮 interrupt=true 仅 1 条(开场那句);cue 5 条、查重 0 组重复。
判据反过来误伤了纯文本(1.0.183~1.0.184)

用户反馈"这已经影响到纯文本输出类播报了"。实测复现,两条都是判据太糙:

1.0.183(散文被当成代码)。实机 04:29 与 04:31 两轮散文创作,日志都是 进入静音模式:本条回复出现代码(已送 0 字) → 全文 1027/1074 字,只念了 303/312 字的结果段。根因是 looksLikeCodeLine 的符号密度那条:白名单里只有中文标点区和全角 ASCII,“”‘’——… 这些通用标点(U+2000–206F)全被算成"符号",于是 **秋日私语**、“不早了。”她说。、他说——这话我记了很多年。 这类正常短行轻而易举超过 30% 阈值,一行命中就把整轮打成静音。修法两条:白名单补上通用标点区;符号多还不够,必须同时出现真正属于代码的半角符号(;{}()[]="$#<>|\/!' 之一,* 不算)。

同一版还把 hasCodeTrace 从"任意一行命中"收紧为"命中两行以上,或命中一行但占非空行一半以上"(单行整段仍直接判真)——散文里偶尔一行小标题不再能一票否决整轮。

1.0.184(读文件被当成写文件)。1.0.183 之前的第二个触发点是"客户端上报 block-type: tool-call 就静音",而 tool-call 块里根本没有工具名(chunk 只有 type/index/blockType)。于是「读一下 README,用中文总结」这种纯文本请求一走工具就被静音(实机 04:46:进入静音模式:模型开始写文件(tool-call 块),全文 150 字只在最后念一遍)。修法:tool-call 块不再作为静音依据,工具名改从 session/event 的 tool/call 事件取——

tool/call 结构:keys=turn,step,callId,name,arguments name=write args={"file_path": "<工作区>/stopwatch.html", ...

只有 name 命中 write|edit|create|patch|apply|save|update 才静音;read/搜索/列目录一律照常逐字念。

同时 block-type 只在类型含 code(真在输出代码块)时才静音。

实机复验(1.0.184):

  • 散文任务:enqueue 35/58/42/40 + 补播 68 字,零静音,逐字念完(含破折号那句);
  • 读文件总结任务:enqueue 59/50 → 实时已全播,skip assistant/message,零静音;
  • 写代码任务(stopwatch.html):block-type tool-call 不静音 → tool/call name=write 才 进入静音模式:工具是写文件/编辑文件 → 静音模式收尾(回合结束):只念结果段 chars=46;
  • 回归脚本 _tmp\quiet-test.cjs 从 18 项扩到 23 项(新增:散文整段不判代码、加粗单行不判、引号短句不判、破折号短句不判、散文夹两行代码仍判、单行代码独占一段仍判);_tmp\fp-probe.cjs 拿 20 条典型散文行逐条探针,误判 0。

取舍照实说:把"一行命中"收紧成"两行以上"后,模型若把一行代码混在四五行说明里摊开(无围栏),这一轮不会被静音,会念出来。这是有意的——宁可多念一句,也不要再让整轮正常回复被吞掉。

静默久了先响一声 + 中途反问补念(1.0.186)

静音模式有两个听感/信息上的窟窿,都是"静了几分钟"这件事的副作用:

一、开口前先"叮"一下。静了三五分钟后突然出声,听众第一反应是"又重播了"。现在静音超过 CHIME_MIN_QUIET_MS = 45_000 且确实还有内容要念,客户端会在开口前先响两声短音(880Hz + 1174Hz,各 0.17 秒),再停 CHIME_GAP_MS = 520 毫秒,然后才念结果段。

  • 提示音是 WebAudio 现合成的,不进合成队列、不走 /state:不占 id、不影响段序、也不用等 Edge TTS 往返。
  • 45 秒是折中:十几秒的短回合静默不明显,多响一声反而吵;写代码的回合通常静默 3~5 分钟,正好需要这一声。判定在服务端(shouldChime),客户端只负责执行。
  • 客户端埋了打点:{"what":"chime","state":"running","peakVolume":0.9}。state 不是 running 说明 AudioContext 被引擎挂起,那一刻是没声音的——排查"提示音没响"先看这一条。
  • 提示音跟随静音开关(manager.muted)与播放音量;提醒失败只吞异常,绝不会拖住后面那句。

二、中途反问要补念。静音回合只念 resultTail 的尾部 320 字:模型要是在正文中间问"你想要哪种配色?"再接着写,这句落在尾部之外,用户得盯着屏幕才知道它在等回复。现在 pendingQuestions() 把它单独拎出来,收尾时先念问句、再念结果段。

判据刻意保守("句型像问句"是弱信号,不能抓太宽):

条件值
句尾必须是 ? / ?
句读中英文句号、问号、叹号、换行都切句
句子长度4~90 字(更长的不像是要你回答的一句话)
关键词至少含一个:你/您/要不要/需不需要/是否/还是/请选择/请确认/告诉我/选一个/哪种/哪个/可以吗/行吗/好吗/需要我/you/your/want me/shall I/would you/do you
数量与字数最多 2 句、合计 110 字,重复只留一次
去重结果段里已经包含的问句不再念

修辞问句("为什么会这样呢?")故意不抓——它不需要回答,单独插一句只会打断听感。

代价:一句都不符合上表的反问(比如只有"深色还是浅色?"、没有第二人称)不会被补念,会跟结果段一起被尾部窗口截掉。要更宽就得接受把修辞问句也念出来,这版选择的是"少插一句"。

实机(1.0.186,时钟任务):

  • 进入静音模式:工具是写文件/编辑文件(name=write) → 记下结果段待念(全文 29 字,中途反问 0 句);
  • 把阈值临时调到 5 秒做验证:静音模式收尾:静了 11s,开口前先响一声提示音 → enqueue chunks=1 chars=29 → 客户端 play-start id=1 与 chime state=running peakVolume=0.9 相继到达;
  • 恢复 45 秒后,两个静默 29~30 秒的写代码回合都没有提示音,只有结果段(阈值边界符合设计)。
  • 回归:新增 _tmp\quiet-ask-test.cjs(24 项,覆盖反问抓取/修辞不抓/英文句、最多两句、超长跳过、合计超限只留第一句、尾部已含不重复、提示音阈值 20/44/45/240 秒与无内容不响);既有 quiet-test(23 项)、fp-probe、tail-test、code-filter-test、clean-test 全过。
排查用的两条日志(1.0.188)

加了这两条之后,"这一轮为什么没念"不用再猜:

  • user/message:新回合,已复位播报闸门与已送文本 —— 每收到一条用户消息打一次。没有这一行就说明宿主没把它当成新回合(例如上一轮还没结束、消息被 agent/inbox/spliced 并进进行中的回合),后面的内容会被上一轮的静音闸门挡掉。
  • 静音闸门拦下实时段 chars=N(codeQuietTurn=…,…) —— 闸门生效时只打第一条,不刷屏。

实机验证过连续两轮写代码(1.0.188):第一轮 user/message 复位 → tool/call write 静音 → 收尾念 21 字;第二轮 05:55:36 user/message:新回合,已复位 → 05:55:58 再次静音 → 收尾念 65 字。两轮都正常。

顺便澄清一次误判:日志里出现 skip assistant/message:静音回合的结果段已念过(49s 前) 不代表"新回合被吞"——那一轮宿主还没发 user/message,到的是同一回合的最终快照,去重是对的。判据就是看上面那条 user/message 有没有出现。

1.0.189 只修一处日志文案:quietSettledAt = 0 时不再打印"安静的 1794376473s 前"这种无意义数字,改写成"本回合还没念过结果段"。

文件名不该被删成"已保存为"(1.0.191)

模型写完文件最常说的是"已保存为 count.html。",界面上 count.html 是文件 chip。清洗时原来的规则是把文件名整段删空(理由是念"count 点 h t m l"像噪音),结果句子被砍成:

已保存为。

听的人第一反应是"已保存为什么?"——话被截断了。用户就是从这个残句报上来的("已保存为什么?已优化,什么已优化?",后者对应列表项 count.html 已优化: 被砍成"已优化:")。

改法:不再删空,按扩展名换成一个能念的名词。

扩展名念作
html / htm网页
md说明文档
css样式表
js / jsx / ts / tsx / py脚本
json数据文件
txt文本文件
csv / xls / xlsx表格
png / jpg / jpeg / gif / svg / webp图片
zip压缩包
doc / docx文档
pdfPDF 文档
其他文件

于是"已保存为网页。""网页已优化:"——句子完整了。

代价照实说:听不出具体是哪个文件(a.html 和 b.html 都念成"网页")。要看是哪个文件得看屏幕。这里的取舍是:宁可听不出名字,也不要听成残句。

顺带修一个副作用:替换后中文之间会留一个空格("已保存为 网页"),TTS 会读成一次停顿,所以在收尾阶段把"中文-空格-中文"的空格抹掉。

回归:新增 _tmp\file-ref-test.cjs(30 项断言:16 种扩展名映射、8 个残句场景、5 项正常文本不受影响——纯散文、小数点 3.14159、无扩展名的 Makefile、中文间空格、网址)。实机:stopwatch2.html 那轮第 1 段合成 43 字,正对应把 stopwatch2.html(15 字)换成"网页"(2 字)后的长度差 13 字,卡片上念的是"已保存为网页。"

顺手修掉同类残渣:<!DOCTYPE html>(1.0.192)

实机复验时又看到一个同类问题:模型说"已在 count.html 的 <!DOCTYPE html> 之后加了一行注释",播报出来是"已在网页的 <1 之后加了一行注释"——<!DOCTYPE html> 没被当标签删掉,留在句子里念成噪音。

原因:删标签的正则要求 < 后紧跟字母(<\/?[A-Za-z][^>]*>),<!DOCTYPE 是 <! 开头,漏网。

修法:在它前面加一条 /<![^>]*>/gu 先删声明,再删普通标签。实机同一句从 31 字降到 14 字,卡片显示"已在网页顶部再加了一行注释。"(干净、通顺)。file-ref-test.cjs 补两项(DOCTYPE 声明、<div class="box">)后 32 项全过。

模型反问不是正文,是选项卡(1.0.194)

现象:让模型"动手之前先问我一两个问题",它确实问了 —— 但正文里一个字都没有,界面直接弹出一张选项卡(标题 + 三个选项 + 输入框)。播报渠道根本收不到这段文字,用户不盯屏幕就不知道它在等回复,整个任务就卡在那。

原因:DSH 里反问走的是工具 ask_user_question(另一个别名 request_user_input),问题文本在 tool/call 事件的 arguments 里,不在 assistant 正文里。1.0.186 做的"中途反问补念"只从正文里抓问句,所以一直抓不到(实机 0 命中就是因为它)。

做法:在 tool/call 分支按工具名接住,从 arguments 里取出 question / questions 字段,念成:

模型在等你回答:当前页面本来就是深色的,你希望「深色模式切换」怎么做?;主题选择要不要记住?

细节:

  • interrupt: true —— 提问优先,打断正在念的内容,把问题先送出去(模型在等回答,这一轮根本不会结束,不能等 turn/end)。
  • 带 chime:前面若静默久了,先响一声再念。
  • 每问 4~160 字、最多 3 问;同一问题 120 秒内不重复(模型重试提问很常见)。
  • 字段名不认识时有兜底:抓任何"带问号的字符串"。宁可多念一句,也别把提问整段漏掉。
  • 只念问题,不念选项(选项太长,念了反而吵)。

实机(15:06):发"给 count.html 加一个深色模式切换,动手之前先问我一两个问题",日志出现

提问工具参数:{"turn":26,"step":1,"callId":"...","name":"ask_user_question","arguments":"{\"questions\": [{\"header\": \"主题方案\", ...}
提问播报:2 问 chars=46(正文里没有这几句)
enqueue chunks=1 chars=46 interrupt=true
synth ok id=1 chars=46 dur=8.832

客户端卡片歌词滚动显示第二问"主题选择要不要记住?"。

离线:_tmp\ask-tool-test.cjs 18 项(真实事件结构、arguments 是对象/字符串、两问、最多三问、太短/超长丢弃、无问号短句收、重复只留一次、垃圾输入不炸、不把 description 当问题、工具名匹配)。

多文件列举别念成一串"网页"(1.0.195)

1.0.191 把文件名换成名词,副作用在多文件任务里被放大:让模型建 5 个页面,播报念成

已为您在工作区创建了 5 个页面文件:网页、网页、网页、网页和网页。

两个问题:听不出是几个文件;卡片一行放不下会左滚,只露出句尾,看着像残句(合成的是完整 59 字,裁切只发生在显示层 —— 客户端按标点分段 + 当前句左滚是既有设计)。

做法是加 FILE_LIST_RE,把连续列举整串一起匹配(顿号、中英文逗号、和/与/及 都认,分隔符两侧允许包反引号):

  • 同类 → N 个网页:a.html、b.html 和 c.html → 3 个网页
  • 混类 → 不折叠、逐项念:a.html、style.css → 网页、样式表(类别不同才有区分意义)
  • 前文已经数过就整串省掉:创建了 5 个页面文件:a.html…e.html → 创建了 5 个页面文件。,残留的 :。 由一条标点清理规则收掉
  • 单个文件完全不受影响:已保存为 count.html 仍是 已保存为网页

反引号那次翻车(务必记住):真机原文里每个文件名各自包着反引号 —— 界面上的文件 chip 就是这种 —— p1.html 与 p2.html 之间的"、"里夹着两个反引号,分隔符匹配不上,整串被切成两段,播报仍是"网页、网页…"。先是在 PowerShell 里内联复现,"看起来通过了 34 字",那是 PowerShell 把反引号当转义符吃掉了,输入里根本没有引号,属于假阳性。正确做法:把分隔符统一成 FILE_SEP_SRC = '\\s*\?\s*(?:[、,,]|和|与|及)\s*`?\s*'(顿号分支和"和/与/及"分支都允许反引号),计数匹配也允许尾随反引号;带反引号的样本必须写进 .cjs文件再跑,不能用node -e` 内联。

离线:_tmp\file-list-test.cjs 23 项(已报数省掉/报数不符/无计数/两个文件/混类/英文逗号/与/及/单文件不回归/句中列举/标点堆积/三类混合/句末无标点/反引号×4)。

真机(16:03,本地 gemma-4-12b 建 q1~q5):只念结果段 chars=34(全文 34),卡片两行是"已为您在工作区创建了 5 个页面文件。/每个页面顶部都写有自己的名字。"——列举整串省掉,不再是一串"网页"。紧接着的单文件回归(r.html):chars=24(全文 24),单文件路径未被改动影响。

同一版还跑了纯文本场景(确认新加的标点清理规则没误伤非文件类文案):散文 21+53 字(零静音、零提示音)、中英夹杂 46+38 字(street lights 开始 flickering、evening vibe 原样念出)、超短回复 4 字("收到了。")。唯一没有真机证据的仍是正文路径的中途反问——模型的反问走 ask_user_question 工具(1.0.194 已真机命中),正文里几乎没有,所以 补念中途反问 N 句 这条日志至今 0 次。

注入坑再说一遍:file-ref-test.cjs、clean-test.cjs 都用 new Function 切函数出来跑,模块级常量不注入就是 ReferenceError(这次漏了 FILE_LIST_RE,两个脚本同时红)。现在它们改成切 const FILE_REF_RE = 到 function fileKindLabel( 的整段常量区,以后加常量不必再动脚本。

同一回合续写不再打断(1.0.171~1.0.173)

1.0.170 时剩下最后一个听感问题:工具调用后宿主重开 attempt,页面按 attempt id 判新旧 → 发 append=false → 服务端 interrupt:true 清队列,正在念的半句被切断。

根因不在页面,在"谁有资格判这是新回复"。现在改成由宿主事件判:

  • 收到 user/message → 这是真·新回合:清空已送文本、置 turnFresh = true,允许下一段实时内容打断旧播报;
  • 同一个回合内的 step/start、tool/call、tool/result、attempt 变化 → 一律续传,interrupt 恒为 false。

turn/start 不能用:它在每个 step 都可能再来一次,用它会把"同一回合续写"误判成新回复,实测仍会出现一次 interrupt=true(1.0.172)。只认 user/message。

配套的两处:

  • enqueue 增加 cleaned: true 选项:调用方已经洗过 cleanForSpeech 就别再洗一遍。两次清洗会让"记进已送文本的字数"和"真正送去合成的内容"对不齐,尾巴补播就会重复。
  • looksLikeCodeLine 的符号密度判据把"不算符号"的范围收窄到中文标点区(U+3000–303F、全角 ASCII U+FF01–FF5E)。半角引号、逗号、井号、方括号、斜杠一律算符号 —— 代码行 '#FF5733', 之前因为半角引号被当成正常字符,符号占比只有 20%,整行就被念出来了。另外新增:含 //、含半角方括号 []、name(...) 调用形、属性访问 a.b、半角冒号 + 英文键(中文正文用的是全角「:」)。

实机结果(1.0.173,俄罗斯方块 + "代码要分开写清楚"):

  • 全程 interrupt=true 只有 1 条,就是用户发消息后的第一段;工具调用后重开 attempt 是续传,没有切断;
  • 50 条 cue,查重只剩 1 组(控制方式: 两次)—— 这一组是正文自己写了两个"控制方式"小节,不是重播;
  • cue 里没有一行代码(shape:、'#FF5733',、// 注释、padding 全部消失);
  • 收尾 补播 assistant/message 尾巴 chars=26(接在已送末尾,已送 391 字)。
播报节奏体检(tools/pacing-report.mjs)

想知道"文字滚动跟不跟得上声音""播报有没有为了显示被切碎、被故意停顿",跑这一条:

cd <插件目录>
& "C:\Program Files\nodejs\node.exe" tools\pacing-report.mjs

它只读插件日志(%USERPROFILE%\.dsh\dsh-live-speak\log.txt),不需要接口、不需要截屏,跑完会打印报告并把同样的内容写进 %USERPROFILE%\.dsh\dsh-live-speak\pacing-report.txt,直接把那个 txt 发回来就行。

它看四个指标

  1. 分段:一次播报被切成几段、每段多少字。正常应该是 120 字上下的整块;如果平均只有十几字,说明音频被按标点切碎了(这是被否掉的方向)。
  2. 每段语速:字数 / 音频时长,各段之间偏差 ≤35% 才算一致。某段明显偏慢,说明那段内部有长静音或被拆开念。
  3. 段间空档:上一段 play-end 到下一段 play-start 的毫秒差,>0.6s 就是在等下一段合成,耳朵能听出断开。
  4. 滚动偏差:每次换句记下"音频实际播到哪"和"这句应该在几点起",差值就是 drift。换句判定走 requestAnimationFrame(约 16ms 一次),所以正常应该只有 0.01s 量级;超过 0.4s 说明哪里不对,超过 0.8s 就是文字和声音脱节。

依赖的打点(客户端三处,--all 可看历史全部场次)

  • play-start:开始播一段时,带 dur(这段多长)和 cues(这段有几行歌词)
  • cue-tick:换句瞬间,带 cueT(该句应在的时间)、actual(实际播放位置)、drift、text
  • play-end:一段播完,带 elapsed(实播时长)与 expect(合成给的时长)

这些只是 POST /debug 日志,不影响播放。想连测两场对比就用 --all,只看最近一次用默认。

空档归因(报告第三节会逐条写出来)

空档有两个完全不同的来源,光看秒数分不出来,所以报告会顺着日志反推:

  1. 在等模型把字写出来:相邻两段 synth ok 之间的间隔,远远超过"这段字正常要合成多久"(估算 max(1.5, 字数 × 0.06 + 1) 秒)。间隔里头大段时间根本没有任务可合成 —— 队列空着,在等上游把文本推过来。这一类跟歌词滚动、跟合成都没关系,是模型出字速度比播报速度慢。
  2. 合成本身慢了:synth ok 晚于上一段 play-end,且前面没有长时间空转 → Edge 握手慢或 429 退避。
  3. 播放链路晚了:合成早就完成,却迟迟没开播 → 轮询或文本拼接的问题。

实测数据(803 字 / 20 段那次):7 处明显空档、累计 25.20s,其中 6 处是第 1 类(最长队列空转 42s 才拿到一段),1 处是第 2 类。也就是说那次播报的停顿主要来自"模型还在写",不是插件这边。

针对第 2 类做的改动:合成泵开两路

pump() 原来是严格串行:一段碰上 429 退避或握手变慢,后面所有段全排队,播放端就听几秒空白。现在 MAX_SYNTH_PARALLEL = 2,两路同时合成,慢的那一段不再堵住别的段;id 改成"取任务时分配",ready 的遍历、trimReady 的淘汰、oldestId 都改成按 id 排序/取最小 —— 因为并发完成后写入顺序不再等于入队顺序。

空档期歌词区不再一律说"没有待播内容"

三种状态分开:正在生成下一句…(模型还在写)、正在合成下一段…(后端队列里还有活)、没有待播内容(真播完了)。几秒静默时看到的是前两种,就不会误以为插件卡死。

関連プラグイン