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:
1 parent
648df0579d
commit
eb9fbabce0
3 files changed
+70
-20
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user