fix: 胶囊波形/玻璃随电平实时跳动 + 词汇本命中后实时刷新

修三件事:

1. **波形不动** — 后端电平回调 ~185 Hz emit,CSS 0.12s transition 导致 22 个并行
   transition 互相覆盖、视觉上被平均成静止。
   - coordinator.rs: 节流 emit 到 30 Hz(33ms 最少间隔)
   - Capsule.tsx: bars transition 改 0.06s linear,每次 emit 完整跳到目标高度

2. **玻璃静止** — 加电平驱动的微缩放 + 投影增强(≤2%),录音态整个胶囊在呼吸。
   transition: transform 0.06s linear, box-shadow 0.06s linear。

3. **词汇本不实时** — record_hits 改完 dictionary.json 但前端不知道,需切 tab 才更新。
   - coordinator.rs: total_hits > 0 时 emit('vocab:updated', total_hits)
   - Vocab.tsx: useEffect 订阅 vocab:updated 后立即 refresh()

不改产品语义;不动 ASR / polish 路径。
This commit is contained in:
baiqing committed 2026-04-30 13:03:23 +08:00
1 parent 648df0579d
commit eb9fbabce0
3 files changed
+70 -20

No files matched your search

+38 -15
View File
@@ -345,24 +345,40 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
};
let inner_for_level = Arc::clone(inner);
// 节流:电平回调本身约 185 Hz(cpal 默认音频块),全部转发到前端会让 CSS
// transition 互相覆盖、视觉上"被平均"成静止。限制为 ~30 Hz(33ms 最少间隔),
// 配合 CSS 短 transition 让每次 emit 完整可见。
let last_emit_at = Arc::new(Mutex::new(None::<Instant>));
const LEVEL_EMIT_MIN_INTERVAL_MS: u64 = 33;
let level_handler: Arc<dyn Fn(f32) + Send + Sync> = Arc::new(move |level| {
let phase = inner_for_level.state.lock().phase;
if phase == SessionPhase::Listening || phase == SessionPhase::Starting {
let elapsed = inner_for_level
.state
.lock()
.started_at
.elapsed()
.as_millis() as u64;
emit_capsule(
&inner_for_level,
CapsuleState::Recording,
level,
elapsed,
None,
None,
);
if phase != SessionPhase::Listening && phase != SessionPhase::Starting {
return;
}
let now = Instant::now();
{
let mut last = last_emit_at.lock();
if let Some(prev) = *last {
if now.duration_since(prev).as_millis() < LEVEL_EMIT_MIN_INTERVAL_MS as u128 {
return;
}
}
*last = Some(now);
}
let elapsed = inner_for_level
.state
.lock()
.started_at
.elapsed()
.as_millis() as u64;
emit_capsule(
&inner_for_level,
CapsuleState::Recording,
level,
elapsed,
None,
None,
);
});
match Recorder::start(consumer, level_handler) {
@@ -479,6 +495,13 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
0
}
};
// 词汇本页面在打开时通常需要立即看到 hits 增长,否则用户得手动切走再切回来才刷新。
// 命中数 > 0 时通知前端:Vocab 页面订阅 vocab:updated 即时 listVocab() 重新加载。
if total_hits > 0 {
if let Some(app) = inner.app.lock().clone() {
let _ = app.emit("vocab:updated", total_hits);
}
}
let session = DictationSession {
id: Uuid::new_v4().to_string(),
+14 -4
View File
@@ -56,9 +56,9 @@ function AudioBars({ level }: AudioBarsProps) {
borderRadius: 999,
background: 'var(--ol-blue)',
opacity: 0.82,
// 注:原版用 cubic-bezier(.5, 1.7, .5, 1) 近似 Swift 弹簧,但 overshoot 与 ~100Hz 电平
// 更新叠加会出现可见抖动。改为非 overshoot 的 ease-out,单次稍硬但稳定不抽搐。
transition: 'height 0.12s cubic-bezier(.4, 0, .2, 1)',
// 后端节流到 ~30 Hz(33ms),CSS 用 0.06s linear 让每次 emit 在下一次到达前
// 已经完整跳到目标高度 → 视觉上是 30 帧/s 的"跳动"动画,不会被叠加平均成静止。
transition: 'height 0.06s linear',
}}
/>
))}
@@ -203,6 +203,12 @@ function Pill({ state, level, insertedChars, message, onCancel, onConfirm }: Pil
center = <AudioBars level={0} />;
}
// 玻璃整体随电平做微缩放 + 投影增强,让"说话时整个胶囊在呼吸"。
// 仅在录音态联动;其他态保持静止。系数控制在 ≤2%,不破坏 176×42 的视觉规格。
const ambient = state === 'recording' ? Math.min(1, Math.max(0, level)) : 0;
const scale = 1 + ambient * 0.018;
const shadowAlpha = 0.20 + ambient * 0.10;
return (
<div
style={{
@@ -220,11 +226,15 @@ function Pill({ state, level, insertedChars, message, onCancel, onConfirm }: Pil
WebkitBackdropFilter: 'blur(28px) saturate(180%)',
border: '1px solid rgba(255, 255, 255, 0.55)',
boxShadow:
'0 18px 50px -10px rgba(0, 0, 0, 0.20),' +
`0 18px 50px -10px rgba(0, 0, 0, ${shadowAlpha.toFixed(3)}),` +
' 0 0 0 0.5px rgba(0, 0, 0, 0.08),' +
' inset 0 0.5px 0 rgba(255, 255, 255, 0.55)',
color: 'var(--ol-ink)',
fontFamily: 'var(--ol-font-sans)',
transform: `scale(${scale.toFixed(4)})`,
transformOrigin: 'center',
transition: 'transform 0.06s linear, box-shadow 0.06s linear',
willChange: 'transform, box-shadow',
}}
>
<CircleButton variant="cancel" enabled={enabled} onClick={onCancel} />
+18 -1
View File
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { addVocab, listVocab, removeVocab, setVocabEnabled } from '../lib/ipc';
import { addVocab, isTauri, listVocab, removeVocab, setVocabEnabled } from '../lib/ipc';
import type { DictionaryEntry } from '../lib/types';
import { Btn, Card, PageHeader } from './_atoms';
@@ -30,6 +30,23 @@ export function Vocab() {
useEffect(() => {
refresh();
// 订阅后端 vocab:updated:每段口述结束、record_hits 触发后由 coordinator 推送。
// Vocab 页面打开期间能即时看到命中数累加,无需切到其他 tab 再切回。
if (!isTauri) return;
let unlisten: (() => void) | undefined;
let cancelled = false;
(async () => {
const { listen } = await import('@tauri-apps/api/event');
const handle = await listen('vocab:updated', () => {
void refresh();
});
if (cancelled) handle();
else unlisten = handle;
})();
return () => {
cancelled = true;
if (unlisten) unlisten();
};
}, []);
const onAdd = async () => {