Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9823ce454 | |||
| 4aec0ef6e9 | |||
| 2979bdd2a3 | |||
| d22e02fa2e | |||
| 7b693b0f63 |
@@ -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"
|
||||
|
||||
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 32 KiB |
@@ -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 截丢尾 final(18C)。
|
||||
// 事件驱动:收到 stop 后的尾部 final(或 usage 结算帧)即触发注入(18C)。
|
||||
// 服务端已保证 stop 后不再下发周期 usage 帧(结算帧必在全部尾部 final
|
||||
// 之后,#23),二者均可安全作为提交信号。800ms 仅作超时兜底:
|
||||
// 12B 实测 8s 会话的 final flush 需 350~540ms,350ms 兜底会截丢尾 final。
|
||||
tokio::select! {
|
||||
_ = finalized.notified() => {}
|
||||
_ = tokio::time::sleep(Duration::from_millis(350)) => {}
|
||||
_ = tokio::time::sleep(Duration::from_millis(800)) => {}
|
||||
}
|
||||
|
||||
let buf = app.state::<CommitBuffer>();
|
||||
|
||||
@@ -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(); // 获焦后才能在失焦时自动收起
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(联调与验收)+ #1C(macOS 注入与浮层)· 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 < 300ms</td><td><span class="tag orange">部分达标</span></td><td>P50 261ms ✅ / P95 918ms ❌(受两轮 provider 异常拖累)</td></tr>
|
||||
<tr><td>12B first_partial P95 < 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_LIMIT(3 分钟截断)</td><td><span class="tag green">通过</span></td><td>198s 推流在 180s 准时截断:先定稿下发全部 final,再发 SESSION_LIMIT</td></tr>
|
||||
<tr><td>12C RATE_LIMITED(30 次/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> feature(cargo 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+Space(3s)</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_100(6000s 余额)</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 && npm run tauri dev</code>,注入走 SendInput(10F)。</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>四、12B 延迟基线(gummy 真实 provider,10 轮 × 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 < 500ms</td><td><span class="tag red">未达标</span></td></tr>
|
||||
<tr><td>release_to_commit</td><td>261ms</td><td>918ms</td><td>< 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/3870ms,release 同步劣化到 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 时就 stop(30 轮中 16 轮 0 partial),定稿要等 provider 收尾 flush</li>
|
||||
<li>体验含义:说「好的」这类两三字短语,松开后约 2 秒才上屏,是当前链路最明显的体验短板</li>
|
||||
</ul>
|
||||
<h3>建议(已入 backlog)</h3>
|
||||
<ul>
|
||||
<li>first_partial P95<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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">provider:gummy-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><500ms</td>
|
||||
<td><span class="tag green">P50 942ms · P95 950ms</span>(极稳定)</td>
|
||||
<td><strong>P95 < 1.2s</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code class="inline">release_to_commit</code>(松键→上屏)</td>
|
||||
<td><300ms</td>
|
||||
<td>短句 ~170ms · 8s 会话 350~500ms</td>
|
||||
<td><strong>≤3s 会话 <300ms;长会话 <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(不含注入耗时 <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 期间(最长 3s)usageLoop 仍在 tick</li>
|
||||
<li><strong>修复:</strong>finish 先冻结会话并停 usageLoop 再 flush——stop 后唯一的 usage 是结算帧且必在全部 final 之后,<strong>五端客户端「final 或 usage 即提交」逻辑自动变安全</strong>,无协议改动。修复后 6/6 归零</li>
|
||||
<li>桌面端收尾兜底 350ms → 800ms(final flush 实测 350~540ms,350ms 会截丢尾 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>
|
||||
@@ -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 帧的推流间隔 ms(50=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)。
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
// latencyprobe:12B 延迟验收工具——走本地网关全链路(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")
|
||||
}
|
||||
@@ -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、暖路径 ~60ms,task-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()
|
||||
|
||||
@@ -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 provider(12B/#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 在途 consumeDelta(16D):
|
||||
// 取 consumeMu 会等待任何已过 finished 检查、正等 Redis 返回的 consumeDelta
|
||||
// 完成并把 trialPart/balancePart 计入,从而快照不漏账。
|
||||
s.mu.Lock()
|
||||
s.finished = true
|
||||
close(s.done)
|
||||
s.mu.Unlock()
|
||||
|
||||
// drain 在途 consumeDelta(16D):finished 已置位,取 consumeMu 会等待任何
|
||||
// 已过 finished 检查、正等 Redis 返回的 consumeDelta 完成并把
|
||||
// trialPart/balancePart 计入,从而快照不漏账。
|
||||
s.consumeMu.Lock() // drain:等当前在途 consume(若有)完成
|
||||
s.consumeMu.Unlock()
|
||||
|
||||
|
||||
@@ -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) -- 17C:seq 计数器与 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) -- 17C:seq 计数器与 ZSET 同寿命,避免按设备永久泄漏
|
||||
redis.call('ZADD', key, now, now .. '-' .. seq .. ':' .. val)
|
||||
redis.call('EXPIRE', key, window + 60)
|
||||
end
|
||||
return 1
|
||||
`)
|
||||
|
||||
|
||||