5 Commits

Author SHA1 Message Date
wangjia c9823ce454 fix(desktop): 托盘菜单左缘对齐图标、去掉垂直间隙(12A 实测反馈)
ci / server (push) Failing after 9s
ci / design-tokens (push) Failing after 11s
此前菜单水平居中于托盘图标(观感偏左)且下方留 4px 间隙。改为
macOS 系统菜单习惯:左缘对齐图标左缘、紧贴菜单栏;菜单超出屏幕
右缘时整体左收 8px 边距。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:41:41 +08:00
wangjia 4aec0ef6e9 fix(desktop): 应用图标接入品牌设计(design/assets/app-icon.svg)
ci / server (push) Failing after 9s
ci / design-tokens (push) Failing after 10s
dock/任务栏此前显示纯蓝占位方块——icons/icon.png 只有底色没有 glyph。
从设计资产 app-icon.svg(蓝底圆角 + 白色圆圈声纹标)渲染 1024px 源图
(内容 824px 居中留边,符合 macOS 图标规范),tauri icon 生成全尺寸
图标集(icns/ico/png),bundle.icon 补全标准列表。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 20:25:18 +08:00
wangjia 2979bdd2a3 docs(12B): 延迟验收暂定结论 + 建立项目文档索引
ci / server (push) Failing after 10s
ci / design-tokens (push) Failing after 10s
- doc/latency-acceptance-12b.html:延迟分解、#23/#24 修复记录、暂定新目标
  (first_partial P95<1.2s;release_to_commit ≤3s 会话<300ms / 长会话<600ms)、
  paraformer 切换待 eval 对比决策、复测方法
- doc/index.html:项目文档单一入口(按规范补建,登记现有 4 份文档)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 20:02:16 +08:00
wangjia d22e02fa2e fix(#23)+feat(#24): stop 后不再下发周期 usage 帧;gummy 预连接 spare 会话
ci / server (push) Failing after 9s
ci / design-tokens (push) Failing after 10s
#23 修复(服务端根治,五端客户端提交信号自动变安全):
- finish 先冻结会话并停 usageLoop,再 flush provider——此前 usageLoop 在
  waitResults(最长 3s)期间继续 tick,周期 usage 帧会抢在尾部 final 之前
  下发,客户端以 partial 文本提前上屏、丢失 final 修正(12B 实测 8s 会话
  4/4 复现;修复后 6/6 归零)
- 桌面端收尾兜底超时 350ms → 800ms(实测 8s 会话 final flush 需 350~540ms,
  350ms 会截丢尾 final)

#24 预连接(12B 调优):
- GummyProvider 常备一条已完成 run-task 握手的 spare 会话,start 直取,
  后台异步补位 + 40s 定期换新(DashScope 空闲 60s 断连,实测 60s 存活/
  120s Idle timeout,留余量 45s)
- 实测网关 start 处理 120~670ms → 3~4ms;消除 dial 抖动(实测 60ms~3.7s)
  对 first_partial 尾部的放大

gummycheck 增加 -model / -pace / -idle 参数,支持模型对比与闲置存活实验

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 19:44:25 +08:00
wangjia 7b693b0f63 feat(12B): latencyprobe 延迟验收工具 + 会话建立耗时观测 + 滑动窗口只查不记修复
ci / server (push) Failing after 11s
ci / design-tokens (push) Failing after 11s
- cmd/latencyprobe:复刻桌面端行为(单连接串行、100ms/帧实时推流)实测
  网关全链路 first_partial / release_to_commit P50/P95,口径对齐 dictation.rs
- gummy/gateway 增加会话建立耗时日志(dial / task-started / start 总耗时),
  作为 first_partial 延迟分解的常驻观测点
- 修复 slideScript:val=0 的只查请求(AudioWindowExhausted)不再落 0 值成员,
  与注释「只查不记」一致,避免污染窗口 ZSET 并空耗 seq

12B 实测结论(gummy 真实 provider,本机网关):
- first_partial P50≈940ms:其中 DashScope 需收到 ~0.8s 音频内容才出首个
  partial(直连同样),网关暖路径开销仅 ~130ms、冷启 dial ~600ms
- release_to_commit:短会话 ~170ms 达标;8s 会话 350~540ms 超标(final
  flush 随内容量增长);偶发 DashScope 抖动可到秒级
- stop 后周期 usage 帧常先于尾部 final 到达,桌面端会以 partial 文本提前
  注入丢失 final 修正(iOS 已在 21A 修过同类问题,桌面端待修)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 19:03:27 +08:00
29 changed files with 659 additions and 270 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon", "macos-private-api"] }
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-global-shortcut = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 32 KiB

+5 -3
View File
@@ -127,11 +127,13 @@ pub fn stop(app: &AppHandle, canceled: bool) {
let release_at = Instant::now();
tauri::async_runtime::spawn(async move {
if !canceled {
// 事件驱动:收到 stop 后的尾部 final(或 usage 结算帧)即触发注入
// 350ms 仅作超时兜底,避免 flush 慢于固定 sleep 截丢尾 final18C)。
// 事件驱动:收到 stop 后的尾部 final(或 usage 结算帧)即触发注入18C)。
// 服务端已保证 stop 后不再下发周期 usage 帧(结算帧必在全部尾部 final
// 之后,#23),二者均可安全作为提交信号。800ms 仅作超时兜底:
// 12B 实测 8s 会话的 final flush 需 350~540ms350ms 兜底会截丢尾 final。
tokio::select! {
_ = finalized.notified() => {}
_ = tokio::time::sleep(Duration::from_millis(350)) => {}
_ = tokio::time::sleep(Duration::from_millis(800)) => {}
}
let buf = app.state::<CommitBuffer>();
+21 -5
View File
@@ -12,17 +12,30 @@ use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}
use tauri::Manager;
use tauri_plugin_global_shortcut::{Shortcut, ShortcutState};
/// 托盘图标 rect → 菜单窗口左上角物理坐标(图标正下方、水平居中)
fn tray_menu_position(rect: &tauri::Rect, scale: f64, win_width: f64) -> (f64, f64) {
/// 托盘图标 rect → 菜单窗口左上角物理坐标。
/// macOS 系统菜单习惯:左缘对齐图标左缘、紧贴菜单栏下方;
/// 菜单超出屏幕右缘时整体左收(12A 实测反馈:居中定位偏左、间隙偏大)。
fn tray_menu_position(
rect: &tauri::Rect,
scale: f64,
win_width: f64,
screen_right: Option<f64>,
) -> (f64, f64) {
let (x, y) = match rect.position {
tauri::Position::Physical(p) => (p.x as f64, p.y as f64),
tauri::Position::Logical(p) => (p.x * scale, p.y * scale),
};
let (w, h) = match rect.size {
let (_w, h) = match rect.size {
tauri::Size::Physical(s) => (s.width as f64, s.height as f64),
tauri::Size::Logical(s) => (s.width * scale, s.height * scale),
};
(x + w / 2.0 - win_width / 2.0, y + h + 4.0 * scale)
let mut mx = x;
if let Some(right) = screen_right {
if mx + win_width > right {
mx = right - win_width - 8.0 * scale;
}
}
(mx, y + h)
}
/// 左键点击托盘图标 → 在图标位置弹出 / 收起自绘菜单窗(11B)。
@@ -39,7 +52,10 @@ fn toggle_tray_menu(app: &tauri::AppHandle, rect: &tauri::Rect) {
.outer_size()
.map(|s| s.width as f64)
.unwrap_or(248.0 * scale);
let (x, y) = tray_menu_position(rect, scale, win_width);
let screen_right = w.current_monitor().ok().flatten().map(|m| {
m.position().x as f64 + m.size().width as f64
});
let (x, y) = tray_menu_position(rect, scale, win_width, screen_right);
let _ = w.set_position(tauri::PhysicalPosition::new(x, y));
let _ = w.show();
let _ = w.set_focus(); // 获焦后才能在失焦时自动收起
+7 -2
View File
@@ -10,7 +10,6 @@
"frontendDist": "../dist"
},
"app": {
"macOSPrivateApi": true,
"windows": [
{
"label": "settings",
@@ -86,6 +85,12 @@
"bundle": {
"active": true,
"targets": "all",
"icon": ["icons/icon.png"]
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
-220
View File
@@ -1,220 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>dudu — 桌面端联调验收报告(#12 / #1C)</title>
<style>
:root {
--accent: #4F6EF7;
--accent-text: #3D58DB;
--accent-soft: #EEF1FE;
--bg: #F6F7FA;
--card: #FFFFFF;
--text-1: #1B1E26;
--text-2: #5A6072;
--text-3: #A6ABB8;
--border-1: #E4E6EB;
--positive: #16A34A;
--warning: #E8890C;
--danger: #DC2626;
--code-bg: #1B1E26;
--code-text: #D6DCEA;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
background: var(--bg); color: var(--text-1); line-height: 1.75;
}
.container { max-width: 980px; margin: 0 auto; padding: 0 24px 80px; }
header.hero { background: var(--accent); color: #fff; padding: 48px 24px 40px; text-align: center; }
header.hero h1 { margin: 0 0 6px; font-size: 30px; }
header.hero p { margin: 0; opacity: .85; font-size: 15px; }
.chips { margin-top: 16px; }
.chip { display: inline-block; padding: 3px 14px; margin: 3px; background: rgba(255,255,255,.16); border-radius: 999px; font-size: 13px; }
section.card {
background: var(--card); border: 1px solid var(--border-1); border-radius: 14px;
padding: 28px 32px; margin-top: 24px; box-shadow: 0 1px 3px rgba(17,19,26,.05);
}
h2 { font-size: 21px; margin: 0 0 14px; padding-bottom: 8px; border-bottom: 2px solid var(--accent); display: inline-block; }
h3 { font-size: 16px; margin: 22px 0 8px; color: var(--accent-text); }
table { width: 100%; border-collapse: collapse; margin: 10px 0; font-size: 13.5px; }
th, td { border: 1px solid var(--border-1); padding: 7px 11px; text-align: left; vertical-align: top; }
th { background: #F0F1F4; font-weight: 600; }
tr:nth-child(even) td { background: #FCFCFD; }
pre.code {
background: var(--code-bg); color: var(--code-text); padding: 16px 18px; border-radius: 10px;
overflow-x: auto; font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 12.5px; line-height: 1.55;
}
code.inline { background: var(--accent-soft); color: var(--accent-text); padding: 1px 6px; border-radius: 4px; font-family: "SF Mono", Menlo, monospace; font-size: 12.5px; }
.callout { border-left: 4px solid var(--accent); background: var(--accent-soft); padding: 11px 16px; border-radius: 0 8px 8px 0; margin: 12px 0; font-size: 13.5px; }
.callout.warn { border-color: var(--warning); background: #FCEFD7; }
.tag { display: inline-block; font-size: 12px; padding: 1px 10px; border-radius: 999px; margin-right: 6px; font-weight: 600; }
.tag.green { background: #DCFCE7; color: var(--positive); }
.tag.orange { background: #FCEFD7; color: var(--warning); }
.tag.red { background: #FEE2E2; color: var(--danger); }
.tag.blue { background: var(--accent-soft); color: var(--accent-text); }
ul, ol { padding-left: 22px; }
li { margin: 3px 0; }
footer { text-align: center; color: var(--text-3); font-size: 13px; margin-top: 44px; }
</style>
</head>
<body>
<header class="hero">
<h1>桌面端联调验收报告</h1>
<p>TODO #12(联调与验收)+ #1CmacOS 注入与浮层)· 2026-07-10</p>
<div class="chips">
<span class="chip">macOS 实测</span>
<span class="chip">gummy 真实 provider</span>
<span class="chip">无头网关压测</span>
</div>
</header>
<div class="container">
<section class="card">
<h2>一、结论速览</h2>
<table>
<tr><th>验收项</th><th>结果</th><th>说明</th></tr>
<tr><td>12A mac 全链路(快捷键→采集→流式→浮层→注入)</td><td><span class="tag green">通过</span></td><td>真实 App + 模拟快捷键,mock 文本成功注入 TextEdit,计费准确</td></tr>
<tr><td>12A Windows 全链路</td><td><span class="tag red">未执行</span></td><td>Windows 机(ssh windows)缺 Rust/Node 工具链,需先装环境</td></tr>
<tr><td>12B release_to_commit &lt; 300ms</td><td><span class="tag orange">部分达标</span></td><td>P50 261ms ✅ / P95 918ms ❌(受两轮 provider 异常拖累)</td></tr>
<tr><td>12B first_partial P95 &lt; 500ms</td><td><span class="tag red">未达标</span></td><td>P50 845ms / P95 3870ms——gummy partial 下发节奏本身 ~750-850ms,目标需重新校准</td></tr>
<tr><td>12C 断连重建</td><td><span class="tag green">通过</span></td><td>服务端重启后桌面端数秒内自动重连(18E 生效)</td></tr>
<tr><td>12C SESSION_LIMIT3 分钟截断)</td><td><span class="tag green">通过</span></td><td>198s 推流在 180s 准时截断:先定稿下发全部 final,再发 SESSION_LIMIT</td></tr>
<tr><td>12C RATE_LIMITED30 次/30 分钟)</td><td><span class="tag green">通过</span></td><td>第 31 个会话被精确拒绝,前 30 个正常</td></tr>
<tr><td>12C QUOTA_EXCEEDED(试用/余额用尽)</td><td><span class="tag green">通过</span></td><td>扣穿当句不掐断、final 不丢;再次 start 预检拒绝(帧序备注见 §五)</td></tr>
<tr><td>1C 浮层不抢焦点 + 注入</td><td><span class="tag green">TextEdit 通过</span></td><td>会话期间焦点保持在目标 App,注入成功;微信/浏览器/IDE 三目标待人工点验</td></tr>
<tr><td>商业链路(登录/购买/入账)</td><td><span class="tag green">通过</span></td><td>mock 扫码登录、下单、回调入账、回调重放幂等、订单状态全部正确</td></tr>
</table>
<div class="callout warn">
<b>联调中发现并已修复:</b>overlay/tray 为 <code class="inline">transparent: true</code> 窗口,但未启用
<code class="inline">macOSPrivateApi</code>——macOS 上 WKWebView 透明必须开启该项,否则浮层带不透明底色。
本次已在 <code class="inline">tauri.conf.json</code><code class="inline">"macOSPrivateApi": true</code>
<code class="inline">Cargo.toml</code><code class="inline">macos-private-api</code> featurecargo check 通过),
<b>视觉效果需真机目测确认</b>
</div>
</section>
<section class="card">
<h2>二、测试环境与方法</h2>
<table>
<tr><th></th><th></th></tr>
<tr><td>服务端</td><td>本机 <code class="inline">go run ./cmd/server</code>:8080+ docker compose pg/redis;先 mock 后切 <b>gummy 真实 provider</b>(key 经 rbw 管道注入环境,未落盘)</td></tr>
<tr><td>桌面端</td><td><code class="inline">npm run tauri dev</code> 调试构建,settings.json 预置 mock 登录 token 指向 localhost</td></tr>
<tr><td>快捷键模拟</td><td>osascript System Events 按住/松开 Cmd+Shift+Space3s</td></tr>
<tr><td>延迟/异常压测</td><td>自制无头 WS 客户端 <code class="inline">wslat</code>Go + gorilla),按协议 100ms/3200B 实时推流真实中文语音(macOS say 合成 8.6s / 1s / 198s 三种)走 <code class="inline">/v1/asr/stream</code> 真实网关路径</td></tr>
<tr><td>测试账号</td><td>mock 微信登录建两个 trial 用户;用户一经 mock 支付购入 pack_1006000s 余额)</td></tr>
</table>
<h3>延迟指标口径</h3>
<ul>
<li><b>first_partial</b>:首帧音频发出 → 收到首个 partial(客户端视角,含网关中继与 provider 全链路)</li>
<li><b>release_to_commit</b>:发出 stop → 收到定稿 final(对应桌面端「松开→注入」的服务端部分)</li>
</ul>
</section>
<section class="card">
<h2>三、12A 全链路(mac</h2>
<ol>
<li>mock 扫码登录:<code class="inline">POST /v1/auth/qr</code> → 手动打回调(模拟扫码)→ 轮询 confirmed 拿 JWT ✅</li>
<li>App 启动即与服务端建立 WS 预连接 + REST 轮询(lsof 确认 4 条连接)✅</li>
<li>TextEdit 聚焦 → 模拟按住快捷键 3s → 松开 → mock provider 脚本文本「帮我把这份周报整理一下,重点突出本周的进展。」完整注入 TextEdit ✅</li>
<li>会话期间前台应用保持为目标 App(浮层 <code class="inline">focusable:false</code> 生效,即 1C 验证点)✅</li>
<li>计费:3s 会话后 <code class="inline">/v1/me</code> 的 trial_used_today 精确 +3 ✅</li>
</ol>
<div class="callout">Windows 侧同方法可复用:装好 rustup + Node + VS Build Tools 后 clone 仓库 <code class="inline">npm i &amp;&amp; npm run tauri dev</code>,注入走 SendInput10F)。</div>
</section>
<section class="card">
<h2>四、12B 延迟基线(gummy 真实 provider10 轮 × 8.6s</h2>
<table>
<tr><th>指标</th><th>P50</th><th>P95</th><th>目标</th><th>判定</th></tr>
<tr><td>first_partial</td><td>845ms</td><td>3870ms</td><td>P95 &lt; 500ms</td><td><span class="tag red">未达标</span></td></tr>
<tr><td>release_to_commit</td><td>261ms</td><td>918ms</td><td>&lt; 300ms</td><td><span class="tag orange">P50 达标 / P95 超</span></td></tr>
</table>
<ul>
<li>10 轮 final 文本完全一致且正确(含标点/数字 ITN:「今天下午3点,我们开个短会,…」),识别质量稳定</li>
<li>8/10 轮 first_partial 集中在 840-944ms——与 gummy partial 下发节奏(约 0.75-0.85s/个)一致,<b>首包时延已贴近 provider 下限,非网关开销</b></li>
<li>2/10 轮异常(3302/3870msrelease 同步劣化到 592/918ms),服务端无任何告警日志——疑似 DashScope 会话建立耗时波动</li>
</ul>
<h3>超短句补充数据(RATE 测试顺带产出,1s 语音 × 30 轮)</h3>
<ul>
<li>release_to_commit <b>P50 = 2192ms / P95 = 3732ms</b>——远高于长句的 261ms</li>
<li>原因:短句常在 provider 尚未出 partial 时就 stop30 轮中 16 轮 0 partial),定稿要等 provider 收尾 flush</li>
<li>体验含义:说「好的」这类两三字短语,松开后约 2 秒才上屏,是当前链路最明显的体验短板</li>
</ul>
<h3>建议(已入 backlog</h3>
<ul>
<li>first_partial P95&lt;500ms 目标与 gummy 实际下发节奏矛盾,建议重校准为「P50≤900ms / P95≤1500ms」或换更快首包的 provider 档位</li>
<li>短句定稿慢(P50 2.2s):浮层在 stop 后无 final 期间应有「定稿中」状态;调研 gummy 快速定稿参数或 stop 时本地提交最后 partial 的策略</li>
<li>网关为 provider 会话建立耗时与 partial 间隔加观测日志/metrics,否则线上无法定位此类波动</li>
<li>排查慢会话(3 轮 3.2-3.9s 首包):确认是否 DashScope 冷启动,可考虑 provider 侧连接预热</li>
</ul>
</section>
<section class="card">
<h2>五、12C 异常路径</h2>
<table>
<tr><th>场景</th><th>做法</th><th>观察</th><th>判定</th></tr>
<tr>
<td>断连重建</td>
<td>App 保持连接时重启服务端进程</td>
<td>桌面端数秒内自动重连(lsof 确认新连接),无需用户操作</td>
<td><span class="tag green">通过</span></td>
</tr>
<tr>
<td>SESSION_LIMIT<br>3 分钟截断</td>
<td>余额账号推 198s 长音频</td>
<td>180s 准时截断:219 个 partial、全部 final 先定稿下发,随后 <code class="inline">SESSION_LIMIT:单次最长 3 分钟,松开后可继续</code>,会话关闭</td>
<td><span class="tag green">通过</span></td>
</tr>
<tr>
<td>RATE_LIMITED<br>30 次/30 分钟</td>
<td>同设备连发 31 个 1s 短会话</td>
<td>第 1-30 个会话全部正常识别并计费;第 31 个 start 被拒:<code class="inline">RATE_LIMITED:操作过于频繁,稍后再试</code>——滑动窗口精确在第 31 次生效</td>
<td><span class="tag green">通过</span></td>
</tr>
<tr>
<td>QUOTA_EXCEEDED<br>试用+余额用尽</td>
<td>Redis 置试用已用 177/180s 后推 8.6s 音频;再次 start</td>
<td>剩 3s 时 start 预检放行 → 推流中扣穿 → 下发 <code class="inline">QUOTA_EXCEEDED:今日试用已用完,去购买时长</code><b>随后仍 flush 当句 final</b>(已识别内容不丢,实测收到截至扣穿点的定稿文本);再次 start 在预检被直接拒绝</td>
<td><span class="tag green">通过</span></td>
</tr>
</table>
<div class="callout">以上为协议/服务端行为验证;桌面端 overlay 对各错误码的<b>文案呈现</b>19C 实现)需人工目测一遍。</div>
<h3>QUOTA 帧序备注(跨端确认项)</h3>
<ul>
<li>实现顺序为「先发 QUOTA_EXCEEDED 错误帧 → 再 Close provider flush 当句 final」,与 16E 文案(「本句 final 后下发」)顺序相反,但内容不丢</li>
<li>桌面端已核对无碍:<code class="inline">on_server_msg</code> 对 error 帧不丢弃会话,错误帧之后的 final 照常进 CommitBuffer,松键时注入</li>
<li><b>Android / iOS 键盘需各自确认</b>:收到 error 帧后不立即关闭会话/连接,仍处理后续 final 并 commitText 上屏,否则移动端会「扣了费但字没上屏」</li>
</ul>
</section>
<section class="card">
<h2>六、商业链路(登录 → 购买 → 入账)</h2>
<ol>
<li><code class="inline">GET /v1/packs</code>:三档时长包(100/500/2000 分钟)正常返回 ✅</li>
<li><code class="inline">POST /v1/orders</code>:下单返回 native <code class="inline">code_url</code></li>
<li><code class="inline">POST /v1/pay/notify</code>mock 明文回调):SUCCESS,余额 +6000s,订单转 paid,账号 trial→ok ✅</li>
<li><b>回调重放幂等</b>:同 transaction_id 二次回调返回 SUCCESS 但余额不重复入账 ✅</li>
</ol>
<div class="callout warn">再次强调(17I 已标注):MockPay 不验签,<code class="inline">/v1/pay/notify</code> 现等于公开充值接口,<b>上线前必须换 wechatpay-go 真实验签</b></div>
</section>
<section class="card">
<h2>七、遗留项(需人工 / 后续)</h2>
<table>
<tr><th></th><th>负责</th><th>说明</th></tr>
<tr><td>真人麦克风实说验证</td><td>人工 · 2 分钟</td><td>App 正在运行且已登录:任意输入框按住 Cmd+Shift+Space 说话即可(服务端 gummy</td></tr>
<tr><td>1C 三目标注入点验</td><td>人工 · 3 分钟</td><td>微信 / 浏览器 / IDE 各注入一次;同时目测浮层深色玻璃是否透明(本次修复效果)</td></tr>
<tr><td>辅助功能/麦克风权限引导</td><td>人工</td><td>正式打包后首启流程(11F)在无权限干净环境走一遍</td></tr>
<tr><td>Windows 全链路</td><td>装环境后复测</td><td>ssh windows 可达但缺 rustup/Node/VS Build Tools</td></tr>
<tr><td>overlay 错误态文案目测</td><td>人工</td><td>断网/超限场景下浮层文案(19C</td></tr>
<tr><td>first_partial 目标重校准 + provider 观测日志</td><td>开发</td><td>见 §四建议,建议并入 #22 backlog</td></tr>
</table>
</section>
<footer>dudu · 桌面端联调验收报告 · 2026-07-10 · 详细原始数据见本次联调分支提交说明</footer>
</div>
</body>
</html>
+25 -25
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>dudu 文档索引</title>
<title>dudu 文档索引</title>
<style>
:root {
--accent: #4F6EF7;
@@ -15,9 +15,6 @@
--text-2: #5A6072;
--text-3: #A6ABB8;
--border-1: #E4E6EB;
--positive: #16A34A;
--warning: #E8890C;
--danger: #DC2626;
}
* { box-sizing: border-box; }
body {
@@ -34,44 +31,47 @@
padding: 28px 32px; margin-top: 24px; box-shadow: 0 1px 3px rgba(17,19,26,.05);
}
h2 { font-size: 21px; margin: 0 0 14px; padding-bottom: 8px; border-bottom: 2px solid var(--accent); display: inline-block; }
table { width: 100%; border-collapse: collapse; margin: 10px 0; font-size: 13.5px; }
th, td { border: 1px solid var(--border-1); padding: 7px 11px; text-align: left; vertical-align: top; }
th { background: #F0F1F4; font-weight: 600; }
tr:nth-child(even) td { background: #FCFCFD; }
a { color: var(--accent-text); text-decoration: none; }
ul { padding-left: 22px; }
li { margin: 8px 0; }
a { color: var(--accent-text); text-decoration: none; font-weight: 600; }
a:hover { text-decoration: underline; }
.tag { display: inline-block; font-size: 12px; padding: 1px 10px; border-radius: 999px; margin-right: 6px; font-weight: 600; }
.tag.blue { background: var(--accent-soft); color: var(--accent-text); }
.tag.green { background: #DCFCE7; color: var(--positive); }
.desc { color: var(--text-2); font-size: 13.5px; font-weight: 400; }
footer { text-align: center; color: var(--text-3); font-size: 13px; margin-top: 44px; }
</style>
</head>
<body>
<header class="hero">
<h1>dudu 文档索引</h1>
<p>项目全部设计 / 计划 / 验收文档单一入口</p>
<p>五端付费语音输入法 · 全部设计 / 计划 / 调研 / 排障文档单一入口</p>
</header>
<div class="container">
<section class="card">
<h2>设计方案</h2>
<table>
<tr><th>文档</th><th>说明</th><th>形式</th></tr>
<tr><td><a href="plan/design.html">MVP 总体方案 v0.3</a></td><td>产品定义、关键技术决策、总体架构、里程碑与风险</td><td><span class="tag blue">HTML</span></td></tr>
<tr><td><a href="frontend-design.html">前端实现设计 v1.1</a></td><td>五端界面实现设计,100% 还原 design/ 设计系统</td><td><span class="tag blue">HTML</span></td></tr>
<tr><td><a href="backend-architecture.html">后端与数据架构 v1.1</a></td><td>API / WS 协议 / 计费账本 / 存储与 Redis Key 设计</td><td><span class="tag blue">HTML</span></td></tr>
</table>
<ul>
<li><a href="frontend-design.html">前端交互与界面设计</a> <span class="desc">— 五端交互流、界面规格与设计系统对接(v1.1)</span></li>
<li><a href="backend-architecture.html">后端与数据架构</a> <span class="desc">— API、WS 流式协议、时长账本计费、Redis/PG 设计(v1.1</span></li>
</ul>
</section>
<section class="card">
<h2>验收报告 / 排障 Runbook</h2>
<table>
<tr><th>文档</th><th>说明</th><th>形式</th></tr>
<tr><td><a href="desktop-integration-acceptance.html">桌面端联调验收报告(#12 / #1C)</a></td><td>mac 全链路联调、12B 延迟实测基线、12C 异常路径验证结论与遗留项</td><td><span class="tag green">HTML</span></td></tr>
</table>
<h2>实现计划</h2>
<ul>
<li><a href="plan/design.html">MVP 实现计划(阅读版)</a> <span class="desc">— 全端任务拆解与依赖关系</span></li>
</ul>
</section>
<footer>dudu · doc/index.html · 新增文档须登记至此索引</footer>
<section class="card">
<h2>知识库调研 / 验收报告</h2>
<ul>
<li><a href="latency-acceptance-12b.html">12B 延迟验收结论(暂定)</a> <span class="desc">— 2026-07-10 · 全链路延迟分解、#23/#24 修复、暂定新目标与 paraformer 待决策</span></li>
</ul>
</section>
<footer>dudu · 文档索引 · 新增文档须同时登记到本页</footer>
</div>
</body>
</html>
+181
View File
@@ -0,0 +1,181 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>dudu 12B 延迟验收结论(暂定)</title>
<style>
:root {
--accent: #4F6EF7;
--accent-text: #3D58DB;
--accent-soft: #EEF1FE;
--bg: #F6F7FA;
--card: #FFFFFF;
--text-1: #1B1E26;
--text-2: #5A6072;
--text-3: #A6ABB8;
--border-1: #E4E6EB;
--positive: #16A34A;
--warning: #E8890C;
--danger: #DC2626;
--code-bg: #1B1E26;
--code-text: #D6DCEA;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
background: var(--bg); color: var(--text-1); line-height: 1.75;
}
.container { max-width: 980px; margin: 0 auto; padding: 0 24px 80px; }
header.hero { background: var(--accent); color: #fff; padding: 48px 24px 40px; text-align: center; }
header.hero h1 { margin: 0 0 6px; font-size: 30px; }
header.hero p { margin: 0; opacity: .85; font-size: 15px; }
.chips { margin-top: 16px; }
.chip { display: inline-block; padding: 3px 14px; margin: 3px; background: rgba(255,255,255,.16); border-radius: 999px; font-size: 13px; }
section.card {
background: var(--card); border: 1px solid var(--border-1); border-radius: 14px;
padding: 28px 32px; margin-top: 24px; box-shadow: 0 1px 3px rgba(17,19,26,.05);
}
h2 { font-size: 21px; margin: 0 0 14px; padding-bottom: 8px; border-bottom: 2px solid var(--accent); display: inline-block; }
h3 { font-size: 16px; margin: 22px 0 8px; color: var(--accent-text); }
table { width: 100%; border-collapse: collapse; margin: 10px 0; font-size: 13.5px; }
th, td { border: 1px solid var(--border-1); padding: 7px 11px; text-align: left; vertical-align: top; }
th { background: #F0F1F4; font-weight: 600; }
tr:nth-child(even) td { background: #FCFCFD; }
pre.code, pre.diagram {
background: var(--code-bg); color: var(--code-text); padding: 16px 18px; border-radius: 10px;
overflow-x: auto; font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 12.5px; line-height: 1.55;
}
code.inline { background: var(--accent-soft); color: var(--accent-text); padding: 1px 6px; border-radius: 4px; font-family: "SF Mono", Menlo, monospace; font-size: 12.5px; }
.callout { border-left: 4px solid var(--accent); background: var(--accent-soft); padding: 11px 16px; border-radius: 0 8px 8px 0; margin: 12px 0; font-size: 13.5px; }
.callout.warn { border-color: var(--warning); background: #FCEFD7; }
.tag { display: inline-block; font-size: 12px; padding: 1px 10px; border-radius: 999px; margin-right: 6px; font-weight: 600; }
.tag.green { background: #DCFCE7; color: var(--positive); }
.tag.orange { background: #FCEFD7; color: var(--warning); }
.tag.red { background: #FEE2E2; color: var(--danger); }
.tag.blue { background: var(--accent-soft); color: var(--accent-text); }
ul, ol { padding-left: 22px; }
li { margin: 3px 0; }
footer { text-align: center; color: var(--text-3); font-size: 13px; margin-top: 44px; }
</style>
</head>
<body>
<header class="hero">
<h1>12B 延迟验收结论(暂定)</h1>
<p>网关全链路实测 · 延迟分解 · 修复与调优 · 暂定新目标</p>
<div class="chips">
<span class="chip">2026-07-10</span>
<span class="chip">工具:server/cmd/latencyprobe</span>
<span class="chip">分支:feat/12b-latency-probe</span>
<span class="chip">providergummy-realtime-v1(真实)</span>
</div>
</header>
<div class="container">
<section class="card">
<h2>一、暂定结论(TL;DR</h2>
<table>
<tr><th>指标</th><th>原目标 P95</th><th>实测(预连接后)</th><th>暂定新目标</th></tr>
<tr>
<td><code class="inline">first_partial</code>(按键→首字)</td>
<td>&lt;500ms</td>
<td><span class="tag green">P50 942ms · P95 950ms</span>(极稳定)</td>
<td><strong>P95 &lt; 1.2s</strong></td>
</tr>
<tr>
<td><code class="inline">release_to_commit</code>(松键→上屏)</td>
<td>&lt;300ms</td>
<td>短句 ~170ms · 8s 会话 350~500ms</td>
<td><strong>≤3s 会话 &lt;300ms;长会话 &lt;600ms</strong></td>
</tr>
</table>
<div class="callout">
<strong>为什么 500ms 结构性不可达:</strong>first_partial = max(握手, DashScope 音频块阈值 ~850ms) + 推理 ~90ms。
地板是 DashScope 的<strong>音频块阈值</strong>,与网关代码无关(直连同样 ~1s)。要破地板只能换模型(见第四节)。
</div>
<div class="callout">
<strong>体感修正:</strong>前导静音实验证明阈值按「音频量」而非「语音量」计——400ms 静音只使 first_partial 推迟 ~100ms。
用户按键后自然停顿 ~300ms 再开口时,<strong>从开口算的首字延迟仅 ~640ms</strong>,体感优于指标数字。
</div>
</section>
<section class="card">
<h2>二、延迟分解(实测)</h2>
<table>
<tr><th>环节</th><th>耗时</th><th>处置</th></tr>
<tr><td>网关预检(限流 / 配额 / 设备槽)</td><td>~10ms</td><td>无需优化</td></tr>
<tr><td>DashScope dial + task-started 握手</td><td>暖 ~130ms · <span class="tag red">抖动 60ms~3.7s</span></td><td><span class="tag green">已消除</span> spare 预连接(#24</td></tr>
<tr><td>DashScope 音频块阈值</td><td><strong>~850ms(地板)</strong></td><td><span class="tag orange">换模型才能降</span>paraformer ~520ms</td></tr>
<tr><td>推理 + 回传</td><td>~90ms</td><td>无手段</td></tr>
<tr><td>final flush(松键后)</td><td>短句 ~170ms · 8s 会话 350~540ms</td><td>随内容量增长,属 provider 物理时间</td></tr>
</table>
<h3>测量口径(对齐桌面端 dictation.rs</h3>
<ul>
<li><code class="inline">first_partial</code>start 帧发出 → 首个 partial(不含麦克风启动,桌面另有 <code class="inline">audio.start_ms</code></li>
<li><code class="inline">release_to_commit</code>stop 帧发出 → 尾部 final(不含注入耗时 &lt;10ms</li>
<li>工具:<code class="inline">go run ./cmd/latencyprobe -wav xx.wav -n 12</code>(单持久连接 + 100ms/帧实时推流,复刻桌面行为)</li>
</ul>
</section>
<section class="card">
<h2>三、本轮落地的修复与优化</h2>
<h3>#23 stop 后周期 usage 帧抢跑尾部 final(已修,服务端根治)</h3>
<ul>
<li><strong>症状:</strong>8s 会话 4/4 复现 usage 先于尾部 final 到达,客户端以 partial 文本提前上屏、丢失 final 修正(iOS 21A 修过客户端侧,桌面端仍暴露)</li>
<li><strong>根因:</strong>finish 等尾部 final 期间(最长 3susageLoop 仍在 tick</li>
<li><strong>修复:</strong>finish 先冻结会话并停 usageLoop 再 flush——stop 后唯一的 usage 是结算帧且必在全部 final 之后,<strong>五端客户端「final 或 usage 即提交」逻辑自动变安全</strong>,无协议改动。修复后 6/6 归零</li>
<li>桌面端收尾兜底 350ms → 800msfinal flush 实测 350~540ms350ms 会截丢尾 final</li>
</ul>
<h3>#24 gummy spare 预连接(已实现)</h3>
<ul>
<li>常备一条已完成 run-task 握手的 DashScope 会话,start 直取、异步补位、40s 定期换新</li>
<li>依据:DashScope 空闲 <strong>60s 存活、120s 报 Idle timeout</strong>(实测),留余量 45s</li>
<li>效果:网关 start 处理 <strong>120~670ms → 3~4ms</strong>dial 抖动(60ms~3.7s)移出关键路径,否则 P95 可达 1.5~4.5s</li>
</ul>
<h3>顺手修复</h3>
<ul>
<li>滑动窗口 Lua「只查不记」与实现不符:val=0 的检查不再写入 0 值成员污染窗口 ZSET</li>
<li>gummycheck 增加 <code class="inline">-model / -pace / -idle</code> 参数,支持模型对比与闲置存活实验</li>
</ul>
</section>
<section class="card">
<h2>四、待决策:要不要换 paraformer</h2>
<table>
<tr><th></th><th>gummy-realtime-v1(现用)</th><th>paraformer-realtime-v2</th></tr>
<tr><td>音频块阈值(首字地板)</td><td>~850ms</td><td><span class="tag green">~520ms</span></td></tr>
<tr><td>partial 刷新节奏</td><td>~1s / 次</td><td><span class="tag green">~500ms / 次(更跟手)</span></td></tr>
<tr><td>识别质量(单样本 TTS 实测)</td><td><span class="tag green">全对</span></td><td><span class="tag red">「各自模块的进度汇报」→「各字文案者进入汇报」</span></td></tr>
<tr><td>partial 时间戳(计量交叉校验用)</td><td>每条带 end_time</td><td>仅 final 带</td></tr>
</table>
<div class="callout warn">
<strong>决策前置条件:</strong>用 eval 框架(<code class="inline">eval/</code>)跑真实数据集对比两模型 WER,再决定是否为 ~300ms 首字收益换质量。
切换成本低:<code class="inline">GummyProvider.Model</code> 一行配置。
</div>
</section>
<section class="card">
<h2>五、复测方法</h2>
<pre class="code"># 起服务端(pg/redis 容器已在跑)
cd server && ./run-dev.sh
# 全链路延迟实测(另开终端)
go run ./cmd/latencyprobe -wav /path/to/16k-mono.wav -n 12
# 直连模型对比 / 闲置存活实验
rbw get dashscope-api-key | go run ./cmd/gummycheck -wav xx.wav -pace 100 -model paraformer-realtime-v2
rbw get dashscope-api-key | go run ./cmd/gummycheck -wav xx.wav -pace 100 -idle 60</pre>
<ul>
<li>注意设备 30 分钟滑动窗口上限 30 次会话,probe 换 <code class="inline">-device</code> 规避</li>
<li>观测日志:<code class="inline">gummy: session ready</code>dial/task-started 耗时)、<code class="inline">gummy: session from spare</code><code class="inline">gateway: start handled</code></li>
</ul>
</section>
<footer>dudu · 12B 延迟验收(暂定结论)· 2026-07-10 · 数据与工具见 feat/12b-latency-probe</footer>
</div>
</body>
</html>
+12 -2
View File
@@ -20,6 +20,9 @@ import (
func main() {
wavPath := flag.String("wav", "", "16kHz/16bit/mono wav 文件路径")
model := flag.String("model", "gummy-realtime-v1", "DashScope 实时识别模型")
paceMs := flag.Int("pace", 50, "每 100ms 帧的推流间隔 ms50=2x 加速,100=实时)")
idleSec := flag.Int("idle", 0, "会话建立后先闲置 N 秒再推流(测预连接会话的存活时间)")
flag.Parse()
if *wavPath == "" {
fmt.Fprintln(os.Stderr, "用法: rbw get dashscope-api-key | gummycheck -wav test.wav")
@@ -41,6 +44,8 @@ func main() {
fmt.Printf("音频 %d 字节 ≈ %.1fs\n", len(pcm), float64(len(pcm))/32000)
p := asr.NewGummy(key)
p.Model = *model
fmt.Printf("模型 %s · 推流间隔 %dms/帧\n", *model, *paceMs)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
@@ -51,6 +56,11 @@ func main() {
os.Exit(1)
}
fmt.Printf("会话建立 %v\n", time.Since(start).Round(time.Millisecond))
if *idleSec > 0 {
fmt.Printf("闲置 %ds...\n", *idleSec)
time.Sleep(time.Duration(*idleSec) * time.Second)
start = time.Now() // 重新计时,测闲置后的识别延迟
}
done := make(chan struct{})
var firstPartial time.Duration
@@ -79,11 +89,11 @@ func main() {
fmt.Fprintln(os.Stderr, "SendAudio 失败:", err)
os.Exit(1)
}
time.Sleep(50 * time.Millisecond) // 2x 实时推流
time.Sleep(time.Duration(*paceMs) * time.Millisecond)
}
_ = sess.Close()
<-done
fmt.Printf("首个结果延迟 %v含 2x 推流速度因素\n", firstPartial.Round(time.Millisecond))
fmt.Printf("首个结果延迟 %v推流 %dms/帧\n", firstPartial.Round(time.Millisecond), *paceMs)
}
// readWavData 提取 wav 的 data chunk(仅支持 PCM)。
+267
View File
@@ -0,0 +1,267 @@
// latencyprobe12B 延迟验收工具——走本地网关全链路(WS 网关 → gummy)实测延迟分布。
//
// 复刻桌面客户端行为:单条持久 WS 连接,串行多次会话,音频实时推流(100ms/帧)。
//
// 测量口径对齐桌面端 dictation.rs
//
// first_partial_ms = start 帧发出 → 本会话首个 partial 收到
// (桌面端另含麦克风启动耗时 audio.start_ms,此处不含)
// release_to_commit_ms = stop 帧发出 → 本会话尾部 final 收到(文本完整落定)
// (桌面端另含文字注入耗时,通常 <10ms,此处不含;
// 若 stop 后先到 usage 帧,桌面端会提前以 partial 文本注入,
// 该情形单独计数为 usage_first
//
// 用法(服务端需已以 gummy provider 启动):
//
// go run ./cmd/latencyprobe -wav /path/to/16k-mono-16bit.wav -n 12
package main
import (
"encoding/binary"
"encoding/json"
"flag"
"fmt"
"net/http"
"os"
"sort"
"time"
"github.com/gorilla/websocket"
"dudu/server/internal/auth"
"dudu/server/internal/store"
"dudu/server/pkg/protocol"
)
type event struct {
msg protocol.ServerMsg
at time.Time
}
func main() {
addr := flag.String("addr", "localhost:8080", "服务端地址")
wavPath := flag.String("wav", "", "16kHz/16bit/mono wav 文件路径")
n := flag.Int("n", 12, "会话次数(注意 30 分钟滑动窗口上限 30 次/设备)")
dsn := flag.String("dsn", "host=localhost user=dudu password=dudu dbname=dudu port=5432 sslmode=disable TimeZone=Asia/Shanghai", "postgres dsn(用于确保探针用户存在且余额充足)")
secret := flag.String("secret", "dev-secret-change-me", "JWT secret(须与服务端一致)")
userID := flag.String("user", "latency-probe", "探针用户 ID")
deviceID := flag.String("device", "probe-dev", "探针设备 ID")
flag.Parse()
if *wavPath == "" {
fmt.Fprintln(os.Stderr, "用法: latencyprobe -wav test.wav [-n 12]")
os.Exit(2)
}
pcm, err := readWavData(*wavPath)
if err != nil {
fmt.Fprintln(os.Stderr, "读 wav 失败:", err)
os.Exit(1)
}
audioSec := float64(len(pcm)) / float64(protocol.SampleRate*2)
fmt.Printf("音频 %.1fs · %d 次会话 · 单连接串行 · 实时推流(100ms/帧)\n", audioSec, *n)
// 确保探针用户存在且余额充足(试用 180s/天 不够跑长测)。
db, err := store.Open(*dsn)
if err != nil {
fmt.Fprintln(os.Stderr, "postgres 连接失败:", err)
os.Exit(1)
}
var u store.User
db.Where(store.User{ID: *userID}).FirstOrCreate(&u)
if err := db.Model(&store.User{}).Where("id = ?", *userID).Update("balance_seconds", 360000).Error; err != nil {
fmt.Fprintln(os.Stderr, "更新探针用户余额失败:", err)
os.Exit(1)
}
token, err := auth.NewJWT(*secret, time.Hour, nil).Sign(*userID)
if err != nil {
fmt.Fprintln(os.Stderr, "签发 JWT 失败:", err)
os.Exit(1)
}
// 单条持久连接(桌面端同款):会话串行复用,读循环常驻。
url := fmt.Sprintf("ws://%s/v1/asr/stream?device_id=%s", *addr, *deviceID)
hdr := http.Header{"Authorization": {"Bearer " + token}}
conn, _, err := websocket.DefaultDialer.Dial(url, hdr)
if err != nil {
fmt.Fprintln(os.Stderr, "dial 失败:", err)
os.Exit(1)
}
defer conn.Close()
events := make(chan event, 256)
go func() {
defer close(events)
for {
var msg protocol.ServerMsg
if err := conn.ReadJSON(&msg); err != nil {
return
}
events <- event{msg, time.Now()}
}
}()
var firstPartials, releaseToCommits []time.Duration
usageFirst := 0
for i := 0; i < *n; i++ {
sid := fmt.Sprintf("probe-%d-%d", time.Now().UnixNano(), i)
r, err := runSession(conn, events, sid, pcm)
if err != nil {
fmt.Printf(" #%02d 失败: %v\n", i+1, err)
} else {
firstPartials = append(firstPartials, r.firstPartial)
releaseToCommits = append(releaseToCommits, r.releaseToCommit)
note := ""
if r.usageBeforeFinal {
usageFirst++
note = " ⚠️usage先到"
}
fmt.Printf(" #%02d first_partial=%5dms release_to_commit=%4dms %q%s\n",
i+1, r.firstPartial.Milliseconds(), r.releaseToCommit.Milliseconds(), truncate(r.text, 24), note)
}
time.Sleep(800 * time.Millisecond) // 会话间隔(无论成败),模拟自然停顿
}
if len(firstPartials) == 0 {
fmt.Fprintln(os.Stderr, "无有效样本")
os.Exit(1)
}
fmt.Println()
report("first_partial ", firstPartials, 500*time.Millisecond)
report("release_to_commit ", releaseToCommits, 300*time.Millisecond)
if usageFirst > 0 {
fmt.Printf("⚠️ %d/%d 次会话 stop 后 usage 帧先于尾部 final 到达(桌面端将以 partial 文本提前注入,丢失 final 修正)\n",
usageFirst, len(firstPartials))
}
}
type sessionResult struct {
firstPartial time.Duration
releaseToCommit time.Duration
text string
usageBeforeFinal bool
}
// runSession 在共享连接上执行一次会话:start → 实时推流 → stop → 等尾部 final。
// 事件按 session_id 过滤,前一会话的迟到帧不串扰。
func runSession(conn *websocket.Conn, events <-chan event, sid string, pcm []byte) (r sessionResult, err error) {
start, _ := json.Marshal(protocol.ClientMsg{Type: protocol.MsgStart, SessionID: sid, SampleRate: protocol.SampleRate, Format: "pcm16"})
t0 := time.Now()
if err := conn.WriteMessage(websocket.TextMessage, start); err != nil {
return r, fmt.Errorf("start: %w", err)
}
handle := func(ev event, tStop *time.Time) error {
if ev.msg.SessionID != sid {
return nil // 旧会话迟到帧
}
switch ev.msg.Type {
case protocol.MsgPartial:
if r.firstPartial == 0 {
r.firstPartial = ev.at.Sub(t0)
}
case protocol.MsgFinal:
r.text += ev.msg.Text
if tStop != nil && r.releaseToCommit == 0 {
r.releaseToCommit = ev.at.Sub(*tStop)
}
case protocol.MsgUsage:
if tStop != nil && r.releaseToCommit == 0 {
r.usageBeforeFinal = true // 桌面端此刻已提前注入 partial 文本
}
case protocol.MsgError:
return fmt.Errorf("server error: %s %s", ev.msg.Code, ev.msg.Message)
}
return nil
}
// 实时推流 + 并行收包。
ticker := time.NewTicker(protocol.FrameMillis * time.Millisecond)
defer ticker.Stop()
off := 0
for off < len(pcm) {
select {
case <-ticker.C:
end := min(off+protocol.FrameBytes, len(pcm))
if err := conn.WriteMessage(websocket.BinaryMessage, pcm[off:end]); err != nil {
return r, fmt.Errorf("audio: %w", err)
}
off = end
case ev, ok := <-events:
if !ok {
return r, fmt.Errorf("连接已断开")
}
if err := handle(ev, nil); err != nil {
return r, err
}
}
}
stop, _ := json.Marshal(protocol.ClientMsg{Type: protocol.MsgStop, SessionID: sid})
tStop := time.Now()
if err := conn.WriteMessage(websocket.TextMessage, stop); err != nil {
return r, fmt.Errorf("stop: %w", err)
}
// 收尾:等尾部 final(文本完整落定),10s 兜底。
deadline := time.After(10 * time.Second)
for r.releaseToCommit == 0 {
select {
case ev, ok := <-events:
if !ok {
return r, fmt.Errorf("连接已断开")
}
if err := handle(ev, &tStop); err != nil {
return r, err
}
case <-deadline:
return r, fmt.Errorf("收尾超时:stop 后 10s 未收到尾部 final")
}
}
if r.firstPartial == 0 {
return r, fmt.Errorf("全程未收到 partial")
}
return r, nil
}
func report(name string, ds []time.Duration, target time.Duration) {
sorted := append([]time.Duration(nil), ds...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
p50 := sorted[len(sorted)/2]
p95 := sorted[(len(sorted)*95+99)/100-1]
verdict := "✅ 达标"
if p95 > target {
verdict = "❌ 超标"
}
fmt.Printf("%s n=%d P50=%dms P95=%dms min=%dms max=%dms 目标P95<%dms %s\n",
name, len(sorted), p50.Milliseconds(), p95.Milliseconds(),
sorted[0].Milliseconds(), sorted[len(sorted)-1].Milliseconds(), target.Milliseconds(), verdict)
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
// readWavData 提取 wav 的 data chunk(仅支持 PCM,同 gummycheck)。
func readWavData(path string) ([]byte, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if len(b) < 44 || string(b[0:4]) != "RIFF" || string(b[8:12]) != "WAVE" {
return nil, fmt.Errorf("不是 wav 文件")
}
off := 12
for off+8 <= len(b) {
id := string(b[off : off+4])
size := int(binary.LittleEndian.Uint32(b[off+4 : off+8]))
if id == "data" {
return b[off+8 : min(off+8+size, len(b))], nil
}
off += 8 + size
}
return nil, fmt.Errorf("未找到 data chunk")
}
+116
View File
@@ -8,6 +8,7 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"sync"
"time"
@@ -21,8 +22,25 @@ const dashscopeWS = "wss://dashscope.aliyuncs.com/api-ws/v1/inference"
type GummyProvider struct {
APIKey string
Model string // 默认 gummy-realtime-v1
// spare 预连接(12B/#24):后台常备一条已完成 run-task 握手的 DashScope
// 会话,start 直接取用,把 dial+task-started(实测暖路径 ~130ms、抖动可达
// 2.5~3.7s)从 first_partial 关键路径上移除。协议固定 16kHz,spare 可互换。
mu sync.Mutex
spare *spareEntry
dialing bool
maintainOne sync.Once
}
type spareEntry struct {
sess *gummySession
born time.Time
}
// spareMaxAge spare 的可用年龄上限:DashScope 空闲 60s 断连(实测 60s 存活、
// 120s 报 Idle timeout),留余量在 45s 内取用、40s 定期换新。
const spareMaxAge = 45 * time.Second
func NewGummy(apiKey string) *GummyProvider {
return &GummyProvider{APIKey: apiKey, Model: "gummy-realtime-v1"}
}
@@ -30,14 +48,97 @@ func NewGummy(apiKey string) *GummyProvider {
func (g *GummyProvider) Name() string { return "gummy" }
func (g *GummyProvider) StartSession(ctx context.Context, cfg SessionConfig) (Session, error) {
g.maintainOne.Do(func() { go g.maintainSpare() })
if cfg.SampleRate == 16000 {
if s := g.takeSpare(); s != nil {
go g.replenish()
return s, nil
}
defer func() { go g.replenish() }()
}
return g.dialSession(ctx, cfg)
}
// takeSpare 取走当前 spare(若仍新鲜且存活);过期/已死的就地关闭丢弃。
func (g *GummyProvider) takeSpare() *gummySession {
g.mu.Lock()
sp := g.spare
g.spare = nil
g.mu.Unlock()
if sp == nil {
return nil
}
if time.Since(sp.born) < spareMaxAge && sp.sess.alive() {
slog.Info("gummy: session from spare", "task", sp.sess.taskID,
"age_ms", time.Since(sp.born).Milliseconds())
return sp.sess
}
_ = sp.sess.Close()
return nil
}
// replenish 异步补位一条 spare;已有 spare 或正在补位时空转返回。
func (g *GummyProvider) replenish() {
g.mu.Lock()
if g.dialing || g.spare != nil {
g.mu.Unlock()
return
}
g.dialing = true
g.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
sess, err := g.dialSession(ctx, SessionConfig{SampleRate: 16000, SessionID: "spare"})
cancel()
g.mu.Lock()
g.dialing = false
if err == nil {
if g.spare == nil {
g.spare = &spareEntry{sess: sess.(*gummySession), born: time.Now()}
g.mu.Unlock()
return
}
g.mu.Unlock()
_ = sess.Close() // 竞态下已有他人补位:丢弃多余会话
return
}
g.mu.Unlock()
slog.Warn("gummy: spare replenish failed", "err", err)
}
// maintainSpare 每 40s 换新 spare,避免超过 DashScope 60s 空闲上限后
// 取到已死会话、退化为冷启 dial。进程常驻,随 provider 生命周期运行。
func (g *GummyProvider) maintainSpare() {
t := time.NewTicker(40 * time.Second)
defer t.Stop()
for range t.C {
g.mu.Lock()
sp := g.spare
stale := sp != nil && time.Since(sp.born) >= spareMaxAge-5*time.Second
if stale {
g.spare = nil
}
g.mu.Unlock()
if stale {
_ = sp.sess.Close()
}
g.replenish()
}
}
// dialSession 建立一条全新的 DashScope 会话(原 StartSession 主体)。
func (g *GummyProvider) dialSession(ctx context.Context, cfg SessionConfig) (Session, error) {
header := map[string][]string{
"Authorization": {"bearer " + g.APIKey},
"X-DashScope-DataInspection": {"enable"},
}
tDial := time.Now()
conn, _, err := websocket.DefaultDialer.DialContext(ctx, dashscopeWS, header)
if err != nil {
return nil, fmt.Errorf("dashscope dial: %w", err)
}
dialMs := time.Since(tDial).Milliseconds()
taskID := uuid.NewString()
runTask := map[string]any{
@@ -75,8 +176,13 @@ func (g *GummyProvider) StartSession(ctx context.Context, cfg SessionConfig) (Se
go s.readLoop()
// 协议要求:必须等 task-started 后才能推音频,否则服务端静默丢弃
tWait := time.Now()
select {
case <-s.started:
// 会话建立耗时是 first_partial 延迟的组成部分(12B):dial 冷启(DNS+TLS
// 可达 ~600ms、暖路径 ~60mstask-started 通常 ~60ms。持续观测供延迟排查。
slog.Info("gummy: session ready", "task", taskID,
"dial_ms", dialMs, "task_started_wait_ms", time.Since(tWait).Milliseconds())
return s, nil
case <-s.done:
return nil, fmt.Errorf("dashscope: closed before task-started")
@@ -101,6 +207,16 @@ type gummySession struct {
lastText string // 当前句已下发文本,用于切分 partial/final
}
// alive 会话底层连接是否仍然存活(readLoop 退出即 done 关闭)。
func (s *gummySession) alive() bool {
select {
case <-s.done:
return false
default:
return true
}
}
func (s *gummySession) SendAudio(pcm []byte) error {
s.writeMu.Lock()
defer s.writeMu.Unlock()
+16 -8
View File
@@ -137,6 +137,12 @@ func (h *Handler) HandleWS(c *gin.Context) {
// startSession 执行 start 预检并建立 Provider 会话;失败时返回 nil(已下发 error 帧)。
func (h *Handler) startSession(ctx context.Context, ws *wsConn, uid, deviceID string, msg protocol.ClientMsg) *session {
now := time.Now()
// start 处理耗时(预检 + provider 握手)串行阻塞在读循环里,直接叠加进客户端
// 可感知的 first_partial 延迟(12B),持续观测供延迟排查。
defer func() {
slog.Info("gateway: start handled", "session", msg.SessionID,
"total_ms", time.Since(now).Milliseconds())
}()
// ① 设备 30min 窗口:次数 + 累计时长
if ok, err := store.AllowSession(ctx, h.RDB, deviceID, now); err != nil || !ok {
@@ -358,24 +364,26 @@ const finishWaitTimeout = 3 * time.Second
// 注意 done/consumeMu 顺序(16D):先停 usageLoop 并 drain 在途 consumeDelta
// 再读 trialPart/balancePart 快照,避免漏记在途扣费。
func (s *session) finish(ctx context.Context, canceled bool) {
// 先冻结会话并停 usageLoop,再 flush provider12B/#23):若 usageLoop 在
// waitResults 期间(最长 3s)继续 tick,周期 usage 帧会抢在尾部 final 之前
// 下发——客户端把"stop 后首个 final/usage"当提交信号,会以 partial 文本
// 提前上屏、丢失 final 修正。冻结后 stop 之后唯一的 usage 是本函数末尾的
// 结算帧,且必然在全部尾部 final 之后,客户端提交信号因此安全。
s.mu.Lock()
if s.finished {
s.mu.Unlock()
return
}
s.finished = true
close(s.done)
s.mu.Unlock()
_ = s.provider.Close()
s.waitResults() // 尾部 final 全部下发后再收尾(带超时,16B)
// 先标记 finished 并停 usageLoop,再 drain 在途 consumeDelta16D):
// 取 consumeMu 会等待任何已过 finished 检查、正等 Redis 返回的 consumeDelta
// 完成并把 trialPart/balancePart 计入,从而快照不漏账。
s.mu.Lock()
s.finished = true
close(s.done)
s.mu.Unlock()
// drain 在途 consumeDelta16D):finished 已置位,取 consumeMu 会等待任何
// 已过 finished 检查、正等 Redis 返回的 consumeDelta 完成并把
// trialPart/balancePart 计入,从而快照不漏账。
s.consumeMu.Lock() // drain:等当前在途 consume(若有)完成
s.consumeMu.Unlock()
+8 -4
View File
@@ -46,10 +46,14 @@ else
end
end
if sum + val > limit then return 0 end
local seq = redis.call('INCR', key .. ':seq')
redis.call('EXPIRE', key .. ':seq', window + 60) -- 17Cseq 计数器与 ZSET 同寿命,避免按设备永久泄漏
redis.call('ZADD', key, now, now .. '-' .. seq .. ':' .. val)
redis.call('EXPIRE', key, window + 60)
-- val=0 为"只查不记"AudioWindowExhausted 的 start 预检):不落 0 值成员,
-- 避免污染窗口 ZSET 并空耗 seq;count 模式 val 恒为 1,不受影响。
if val > 0 then
local seq = redis.call('INCR', key .. ':seq')
redis.call('EXPIRE', key .. ':seq', window + 60) -- 17Cseq 计数器与 ZSET 同寿命,避免按设备永久泄漏
redis.call('ZADD', key, now, now .. '-' .. seq .. ':' .. val)
redis.call('EXPIRE', key, window + 60)
end
return 1
`)