feat(web): 语音手势两端统一:按下时长判别,PC 鼠标也可按住说话(bump 0.48.1)
- 不再按 pointerType 分流:按住 ≥300ms = 按住说话(松开转写,上滑取消); 快速点一下 = 连续录音免持(说完 Enter / 再点结束,Esc 取消)—— PC 手机同一心智模型 - press 发起的录音,松手后合成 click 全压掉;"点按钮停止连续录音"的 press 不压(_pressActive) - getUserMedia 完成前松手按同一时长规则收尾;键盘激活按钮兜底走 click 起录 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e911eb2c6b
commit
7b20cae85b
|
|
@ -23,6 +23,7 @@
|
||||||
|
|
||||||
### 2026-07
|
### 2026-07
|
||||||
|
|
||||||
|
- **07-07 / 0.48.1**:语音手势两端统一(用户提出 PC 也要能按住说话)——不再按 pointerType 分流,改按**按下时长**判别,PC 鼠标/手机触屏同一心智模型:按住 ≥300ms = 按住说话(松开即转写,上滑取消);快速点一下 <300ms = 转成连续录音免持(说完 Enter / 再点结束,Esc 取消;鼠标按住 60s 会累且易失手,长口述场景必须保留免持)。实现要点:press 发起的录音松手后合成 click 全压掉(防"松开转写完又开新录音"/"短按刚转免持被自己 click 停掉"),"点按钮停止连续录音"的 press 不压(`_pressActive` 区分);getUserMedia 完成前松手按同一时长规则收尾;键盘激活按钮(无 pointer 事件)兜底走 click 起录。
|
||||||
- **07-07 / 0.48.0**:语音输入手势升级(纯前端,后端零改动)——业界对齐调研后两端分治:① **触屏按住说话**(微信/DeepSeek 式):`pointerdown`(pointerType≠mouse)按住录音、松开转写、上滑 60px 进入"松开取消"(面板变红),pointer capture 保证滑出按钮仍收事件,touch 后合成 click 用时间窗压掉;② **桌面点击开停 + Enter/Esc 收尾**:说完按 Enter 结束转写、Esc 取消(capture 拦截防落回 textarea 触发发送)。**明确不做桌面 VAD 静音自动停**:口述需求"边想边说"停顿 3~5s 是常态,自动截断比多按一下更伤,业界听写类(ChatGPT/Google Docs/Win+H)也全是手动收尾,自动停只属于 Siri 式短指令场景。③ 录音期 `#voice-rec-panel` 替换 textarea:实时波浪(采集 chunk 顺手算 RMS,1/8 抽样,零额外开销)+ 计时 + 手势提示;iOS Safari 手势内 `ctx.resume()`;授权弹窗期间松手 → 启动完成立即收尾。
|
- **07-07 / 0.48.0**:语音输入手势升级(纯前端,后端零改动)——业界对齐调研后两端分治:① **触屏按住说话**(微信/DeepSeek 式):`pointerdown`(pointerType≠mouse)按住录音、松开转写、上滑 60px 进入"松开取消"(面板变红),pointer capture 保证滑出按钮仍收事件,touch 后合成 click 用时间窗压掉;② **桌面点击开停 + Enter/Esc 收尾**:说完按 Enter 结束转写、Esc 取消(capture 拦截防落回 textarea 触发发送)。**明确不做桌面 VAD 静音自动停**:口述需求"边想边说"停顿 3~5s 是常态,自动截断比多按一下更伤,业界听写类(ChatGPT/Google Docs/Win+H)也全是手动收尾,自动停只属于 Siri 式短指令场景。③ 录音期 `#voice-rec-panel` 替换 textarea:实时波浪(采集 chunk 顺手算 RMS,1/8 抽样,零额外开销)+ 计时 + 手势提示;iOS Safari 手势内 `ctx.resume()`;授权弹窗期间松手 → 启动完成立即收尾。
|
||||||
- **07-07 / 0.47.0**:web 端语音输入(讯飞语音听写 IAT)——聊天输入区新增「🎙 语音」按钮:点击录音(Web Audio ScriptProcessor 采 PCM → 前端线性插值降采样 16k/16bit/mono)→ 再点停止 → `POST /v1/asr/transcribe`(裸 PCM body,require_user)→ 后端 `core/asr_xfyun.py` 连讯飞 wss(HMAC-SHA256 签名,9600B/帧 40ms 节奏,vad_eos=10000 防句间停顿截尾,未开 dwa 整段拼接)→ 文本 insertText 插输入框光标处(接原生 undo 栈,**不自动发送**)。60s 上限自动停(讯飞单会话限制);录音需 HTTPS/localhost;渠道只读镜像 task 按钮锁定;`XFYUN_APPID/API_KEY/API_SECRET` env 未配返 501(**当前 .env 为测试 key,待换正式**)。诊断 `scripts/diag_asr.py`(静音冒烟 / 带 PCM 文件真测);requirements 显式加 `websockets`。已实测:TTS 合成中文 5.2s 音频经端点转写逐字正确。企微语音消息(AMR 需 ffmpeg 转码)留待二期;将来流式逐字上屏可复用 `build_auth_url` 签临时 URL。
|
- **07-07 / 0.47.0**:web 端语音输入(讯飞语音听写 IAT)——聊天输入区新增「🎙 语音」按钮:点击录音(Web Audio ScriptProcessor 采 PCM → 前端线性插值降采样 16k/16bit/mono)→ 再点停止 → `POST /v1/asr/transcribe`(裸 PCM body,require_user)→ 后端 `core/asr_xfyun.py` 连讯飞 wss(HMAC-SHA256 签名,9600B/帧 40ms 节奏,vad_eos=10000 防句间停顿截尾,未开 dwa 整段拼接)→ 文本 insertText 插输入框光标处(接原生 undo 栈,**不自动发送**)。60s 上限自动停(讯飞单会话限制);录音需 HTTPS/localhost;渠道只读镜像 task 按钮锁定;`XFYUN_APPID/API_KEY/API_SECRET` env 未配返 501(**当前 .env 为测试 key,待换正式**)。诊断 `scripts/diag_asr.py`(静音冒烟 / 带 PCM 文件真测);requirements 显式加 `websockets`。已实测:TTS 合成中文 5.2s 音频经端点转写逐字正确。企微语音消息(AMR 需 ffmpeg 转码)留待二期;将来流式逐字上屏可复用 `build_auth_url` 签临时 URL。
|
||||||
- **07-07 / 0.46.2**:消息分页窗口对齐轮次边界(纯前端,`chat.js`)——原按固定 30 条翻页/跳转,窗口边界常切在 assistant 工具调用序列中间(顶部出现孤儿 tool 消息),点目录圆点用 `before_idx=idx+11` 启发式(长轮拉不全、短轮混无关上文)。业界调研确认:分页单位保持消息条数是共识(Discord/OpenAI 均按条 + 每页上限,轮次大小无上界不能当传输单位),轮次归组属渲染层职责。实现:idx 连续 + outline 已含全部 user 轮次起点 → 前端可精确算区间,后端 API 零改动。① 向上翻页 `alignedEarlierLimit`:从窗口顶往前按整轮累加攒够 30 条,新窗口顶恒为 user 消息;② 圆点跳转:`after_idx=起点-1` + 相邻边界差精确拉整轮(短轮补到 30 铺满、超大轮按 `MSG_FETCH_MAX=100` 截断,后续向上加载逐步自愈对齐);③ outline 未加载/idx 不在 outline → 回退旧按条行为。后端 limit 无 clamp,前端以 100 硬上限自律。
|
- **07-07 / 0.46.2**:消息分页窗口对齐轮次边界(纯前端,`chat.js`)——原按固定 30 条翻页/跳转,窗口边界常切在 assistant 工具调用序列中间(顶部出现孤儿 tool 消息),点目录圆点用 `before_idx=idx+11` 启发式(长轮拉不全、短轮混无关上文)。业界调研确认:分页单位保持消息条数是共识(Discord/OpenAI 均按条 + 每页上限,轮次大小无上界不能当传输单位),轮次归组属渲染层职责。实现:idx 连续 + outline 已含全部 user 轮次起点 → 前端可精确算区间,后端 API 零改动。① 向上翻页 `alignedEarlierLimit`:从窗口顶往前按整轮累加攒够 30 条,新窗口顶恒为 user 消息;② 圆点跳转:`after_idx=起点-1` + 相邻边界差精确拉整轮(短轮补到 30 铺满、超大轮按 `MSG_FETCH_MAX=100` 截断,后续向上加载逐步自愈对齐);③ outline 未加载/idx 不在 outline → 回退旧按条行为。后端 limit 无 clamp,前端以 100 硬上限自律。
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.48.0"
|
__version__ = "0.48.1"
|
||||||
|
|
|
||||||
|
|
@ -1470,11 +1470,13 @@ $("chat-optimize").onclick = optimizePrompt;
|
||||||
// ── 语音输入(讯飞 IAT 整段转写)──────────────────────────────────────────────
|
// ── 语音输入(讯飞 IAT 整段转写)──────────────────────────────────────────────
|
||||||
// 采集:Web Audio ScriptProcessor 采 PCM → 停止后降采样 16k/16bit → POST
|
// 采集:Web Audio ScriptProcessor 采 PCM → 停止后降采样 16k/16bit → POST
|
||||||
// /v1/asr/transcribe → 文本插到输入框光标处(insertText 接原生 undo 栈,不自动
|
// /v1/asr/transcribe → 文本插到输入框光标处(insertText 接原生 undo 栈,不自动
|
||||||
// 发送)。两种手势共用同一条采集/转写链路(录音需 HTTPS / localhost;60s 讯飞上限自动停):
|
// 发送)。手势两端统一(PC 鼠标 / 手机触屏同一心智模型),按下时长判别,共用同一条
|
||||||
// - 桌面(鼠标点击):点开始 → 说完 Enter / 再点一次结束并转写,Esc 取消。
|
// 采集/转写链路(录音需 HTTPS / localhost;60s 讯飞上限自动停):
|
||||||
|
// - 按住(≥300ms)= 按住说话:松开即转写,上滑超阈值松开取消(微信 / DeepSeek 式)。
|
||||||
|
// - 快速点一下(<300ms)= 连续录音(免持,长口述不用一直按着 —— 鼠标按住 60s 会累且
|
||||||
|
// 易失手):说完 Enter / 再点一下结束并转写,Esc 取消。
|
||||||
// 注:不做 VAD 静音自动停 —— 口述任务需求时"边想边说"停顿 3~5s 是常态,自动截断
|
// 注:不做 VAD 静音自动停 —— 口述任务需求时"边想边说"停顿 3~5s 是常态,自动截断
|
||||||
// 比多按一下更伤;业界听写类(ChatGPT/Google Docs/Win+H)也全是手动收尾。
|
// 比多按一下更伤;业界听写类(ChatGPT/Google Docs/Win+H)也全是手动收尾。
|
||||||
// - 触屏(按住说话):按住录音,松开转写,上滑超过阈值松开取消(微信 / DeepSeek 式)。
|
|
||||||
// 录音期 #voice-rec-panel 替换 textarea:实时波浪(录音 chunk 顺手算 RMS,零额外
|
// 录音期 #voice-rec-panel 替换 textarea:实时波浪(录音 chunk 顺手算 RMS,零额外
|
||||||
// 采集开销)+ 计时 + 手势提示。
|
// 采集开销)+ 计时 + 手势提示。
|
||||||
const ASR_MAX_SECONDS = 60;
|
const ASR_MAX_SECONDS = 60;
|
||||||
|
|
@ -1569,8 +1571,17 @@ async function startVoiceInput(mode, startY) {
|
||||||
tick();
|
tick();
|
||||||
_drawWave();
|
_drawWave();
|
||||||
$("chat-hint").textContent = "录音中…";
|
$("chat-hint").textContent = "录音中…";
|
||||||
// 按住说话:授权弹窗期间手指已松开 → 启动完成立即按"松开"收尾(通常太短走取消)
|
// 按住说话但 getUserMedia 完成前已松开(快速点按 / 授权弹窗期间松手):
|
||||||
if (mode === "hold" && _holdReleased) stopVoiceInput(_voice.cancelArmed);
|
// 短按 → 照常转成连续录音;长按(多半是授权弹窗耗掉的)→ 按"松开"收尾(太短走取消)
|
||||||
|
if (mode === "hold" && _holdReleased) {
|
||||||
|
if (Date.now() - _pressT0 < HOLD_TOGGLE_MS && !_voice.cancelArmed) {
|
||||||
|
_voice.mode = "click";
|
||||||
|
btn.textContent = "■ 停止";
|
||||||
|
_vrHint("说完按 Enter 结束 · Esc 取消");
|
||||||
|
} else {
|
||||||
|
stopVoiceInput(_voice.cancelArmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopVoiceInput(discard) {
|
async function stopVoiceInput(discard) {
|
||||||
|
|
@ -1621,19 +1632,27 @@ async function stopVoiceInput(discard) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 手势接线:触屏(pointerType touch/pen)按住说话,鼠标走 click 开/停。触屏松手后
|
// 手势接线(两端统一,按下时长判别):按住 ≥300ms = 按住说话(松开转写);短按 <300ms
|
||||||
// 浏览器会补发合成 click,用时间窗压掉,避免"松开转写完又立刻开始新录音"。
|
// = 转成连续录音(免持)。press 发起的录音,其松手后浏览器补发的合成 click 一律压掉 ——
|
||||||
|
// 否则"松开转写完又立刻开新录音"/"短按刚转免持就被自己的 click 停掉"。点按钮停止连续
|
||||||
|
// 录音的那次 press 不压(pointerdown 因 _voice 存在没发起录音,_pressActive=false)。
|
||||||
|
// 键盘激活按钮(Space/Enter,无 pointer 事件)兜底走 click 起录。
|
||||||
|
const HOLD_TOGGLE_MS = 300;
|
||||||
const _micBtn = $("chat-mic");
|
const _micBtn = $("chat-mic");
|
||||||
|
let _pressT0 = 0; // pointerdown 时刻,判短按/长按
|
||||||
|
let _pressActive = false; // 本次 press 确实发起了录音
|
||||||
|
|
||||||
_micBtn.addEventListener("pointerdown", (e) => {
|
_micBtn.addEventListener("pointerdown", (e) => {
|
||||||
if (e.pointerType === "mouse") return;
|
e.preventDefault(); // 触屏压掉长按选中/滚动(CSS 已 touch-action:none);鼠标压掉拖选
|
||||||
e.preventDefault(); // 压掉长按选中 / 页面滚动(按钮 CSS 已 touch-action:none)
|
|
||||||
if (_voice || _micBtn.disabled) return;
|
if (_voice || _micBtn.disabled) return;
|
||||||
|
_pressActive = true;
|
||||||
|
_pressT0 = Date.now();
|
||||||
_holdReleased = false;
|
_holdReleased = false;
|
||||||
try { _micBtn.setPointerCapture(e.pointerId); } catch (err) {} // 手指滑出按钮仍收 move/up
|
try { _micBtn.setPointerCapture(e.pointerId); } catch (err) {} // 滑出按钮仍收 move/up
|
||||||
startVoiceInput("hold", e.clientY);
|
startVoiceInput("hold", e.clientY);
|
||||||
});
|
});
|
||||||
_micBtn.addEventListener("pointermove", (e) => {
|
_micBtn.addEventListener("pointermove", (e) => {
|
||||||
if (e.pointerType === "mouse" || !_voice || _voice.mode !== "hold") return;
|
if (!_voice || _voice.mode !== "hold") return;
|
||||||
const armed = (_voice.startY - e.clientY) > HOLD_CANCEL_PX;
|
const armed = (_voice.startY - e.clientY) > HOLD_CANCEL_PX;
|
||||||
if (armed !== _voice.cancelArmed) {
|
if (armed !== _voice.cancelArmed) {
|
||||||
_voice.cancelArmed = armed;
|
_voice.cancelArmed = armed;
|
||||||
|
|
@ -1642,10 +1661,19 @@ _micBtn.addEventListener("pointermove", (e) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const _holdEnd = (e) => {
|
const _holdEnd = (e) => {
|
||||||
if (e.pointerType === "mouse") return;
|
if (!_pressActive) return;
|
||||||
|
_pressActive = false;
|
||||||
_suppressClickUntil = Date.now() + 700;
|
_suppressClickUntil = Date.now() + 700;
|
||||||
_holdReleased = true;
|
_holdReleased = true;
|
||||||
if (_voice && _voice.mode === "hold") stopVoiceInput(_voice.cancelArmed || e.type === "pointercancel");
|
if (!_voice || _voice.mode !== "hold") return; // 还在 getUserMedia → startVoiceInput 末尾兜底
|
||||||
|
if (e.type !== "pointercancel" && !_voice.cancelArmed && Date.now() - _pressT0 < HOLD_TOGGLE_MS) {
|
||||||
|
// 短按 → 连续录音(免持):说完 Enter / 再点一下结束
|
||||||
|
_voice.mode = "click";
|
||||||
|
_micBtn.textContent = "■ 停止";
|
||||||
|
_vrHint("说完按 Enter 结束 · Esc 取消");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopVoiceInput(_voice.cancelArmed || e.type === "pointercancel");
|
||||||
};
|
};
|
||||||
_micBtn.addEventListener("pointerup", _holdEnd);
|
_micBtn.addEventListener("pointerup", _holdEnd);
|
||||||
_micBtn.addEventListener("pointercancel", _holdEnd);
|
_micBtn.addEventListener("pointercancel", _holdEnd);
|
||||||
|
|
@ -1653,7 +1681,7 @@ _micBtn.addEventListener("contextmenu", (e) => e.preventDefault()); // 长按
|
||||||
_micBtn.addEventListener("click", () => {
|
_micBtn.addEventListener("click", () => {
|
||||||
if (Date.now() < _suppressClickUntil) return;
|
if (Date.now() < _suppressClickUntil) return;
|
||||||
if (_voice) { if (_voice.mode === "click") stopVoiceInput(false); }
|
if (_voice) { if (_voice.mode === "click") stopVoiceInput(false); }
|
||||||
else startVoiceInput("click");
|
else startVoiceInput("click"); // 仅键盘激活可达(press 路径的 click 已被压掉)
|
||||||
});
|
});
|
||||||
// 桌面录音收尾快捷键(capture 拦截,防 Enter 落回 textarea 触发发消息)
|
// 桌面录音收尾快捷键(capture 拦截,防 Enter 落回 textarea 触发发消息)
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue