diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index 31f5781c..e7ddb5d3 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -7,7 +7,7 @@ import { getCapsulePillMetrics, } from '../lib/capsuleLayout'; import { getSettings, invokeOrMock, isTauri } from '../lib/ipc'; -import { playRecordStartCue, stopAudioCue } from '../lib/audioCue'; +import { playRecordStartCue, primeAudioCue, stopAudioCue } from '../lib/audioCue'; import type { CapsulePayload, CapsuleState, UserPreferences } from '../lib/types'; interface AudioBarsProps { @@ -366,6 +366,13 @@ export function Capsule() { }; }, []); + // 预热 AudioContext:胶囊挂载时就 resume,让录音开始时提示音能同步播放, + // 避免 suspended→resume 的异步竞态在「快速录音」时整段丢音(提示音偶尔消失的根因)。 + useEffect(() => { + if (!isTauri) return; + primeAudioCue(); + }, []); + // 提示音触发:检测 capsule 状态进入 recording 的边沿就播放(提醒「已开始录音」); // 离开 recording 则停掉,避免连按热键时残留尾音。独立于 showCapsule —— 胶囊隐藏也会响。 useEffect(() => { diff --git a/openless-all/app/src/lib/audioCue.test.ts b/openless-all/app/src/lib/audioCue.test.ts index 5c6782de..17bbaf43 100644 --- a/openless-all/app/src/lib/audioCue.test.ts +++ b/openless-all/app/src/lib/audioCue.test.ts @@ -2,7 +2,12 @@ // (无独立 runner —— 在 tsc 类型检查下编译,必要时可用 tsx 直接跑)。 // 播放/停止依赖 Web Audio 运行时,不在此单测覆盖;这里只钉住可被回归的音符规划。 -import { cueTotalDurationMs, recordStartCueTones, type CueTone } from './audioCue'; +import { + cueTotalDurationMs, + recordStartCueTones, + shouldPlayDeferredCue, + type CueTone, +} from './audioCue'; function assert(cond: boolean, name: string) { if (!cond) throw new Error(`assertion failed: ${name}`); @@ -45,5 +50,63 @@ function assertEqual(actual: T, expected: T, name: string) { assert(cueTotalDurationMs(recordStartCueTones()) > 0, 'start cue has positive total duration'); } +{ + // 挂起期间被更新一轮播放接管 → 不播,避免叠音。 + assertEqual( + shouldPlayDeferredCue({ + superseded: true, + stoppedWhileWaiting: false, + elapsedMs: 10, + lateThresholdMs: 400, + }), + false, + 'superseded cue does not play', + ); + // 没被打断 → 正常播放。 + assertEqual( + shouldPlayDeferredCue({ + superseded: false, + stoppedWhileWaiting: false, + elapsedMs: 5000, + lateThresholdMs: 400, + }), + true, + 'cue plays when nothing interrupted it', + ); + // 修复点:快速录音——resume 期间录音已停,但 resume 很快(未超阈值)→ 仍补响一声。 + assertEqual( + shouldPlayDeferredCue({ + superseded: false, + stoppedWhileWaiting: true, + elapsedMs: 120, + lateThresholdMs: 400, + }), + true, + 'quick recording still gets a slightly-late cue', + ); + // 录音已停且 resume 真迟到(超阈值)→ 丢弃,避免提示音姗姗来迟。 + assertEqual( + shouldPlayDeferredCue({ + superseded: false, + stoppedWhileWaiting: true, + elapsedMs: 800, + lateThresholdMs: 400, + }), + false, + 'genuinely late cue is dropped', + ); + // 边界:恰好等于阈值不算迟到(> 才丢),仍补播。 + assertEqual( + shouldPlayDeferredCue({ + superseded: false, + stoppedWhileWaiting: true, + elapsedMs: 400, + lateThresholdMs: 400, + }), + true, + 'cue at exactly the threshold still plays', + ); +} + // 静默成功难以与「没跑」区分;直接 tsx 跑时给个明确通过信号。 console.log('[audioCue.test] all assertions passed'); diff --git a/openless-all/app/src/lib/audioCue.ts b/openless-all/app/src/lib/audioCue.ts index ef7a79f8..f23415b2 100644 --- a/openless-all/app/src/lib/audioCue.ts +++ b/openless-all/app/src/lib/audioCue.ts @@ -47,10 +47,39 @@ interface ActiveVoice { gain: GainNode; } let activeVoices: ActiveVoice[] = []; -// 每次「关闭」或「新一轮播放」自增。suspended 时 play 会等 resume() 再排期, -// 这个代号让挂起的 resume 回调能判断「等待期间是否已被叫停/被新一轮取代」, -// 避免录音已经结束、提示音却姗姗来迟地响起来(冷启动 WebView 上快按热键可复现)。 -let playGeneration = 0; +// suspended 的 AudioContext 要先 resume(异步)再排期。等待 resume 期间可能发生两类事件, +// 用两个序号分别记,避免两个极端——「快速录音整段丢音」和「录音停了提示音才姗姗来迟」: +// playSeq —— 每次新的播放请求自增;resume 回来若已被更新一轮播放接管,本次让位(防叠音)。 +// stopSeq —— 每次 stopAudioCue 自增;resume 回来若期间发生过 stop,再按「是否真迟到」决定。 +let playSeq = 0; +let stopSeq = 0; + +// resume 期间录音已结束(发生过 stop)时,只有当「请求 → resume 完成」耗时超过这个阈值 +// 才判定真迟到并丢弃;阈值内仍补响一声——快速点一下录音(resume 没跑完就已结束)也该有反馈。 +const DEFERRED_CUE_LATE_THRESHOLD_MS = 400; + +// 无 performance(理论兜底,Tauri WebView 里恒有)时回退 0:elapsedMs 恒为 0、永不判迟到, +// 即宁可补播一声也不丢音——与"修复丢音"的初衷一致的安全方向。 +function nowMs(): number { + return typeof performance !== 'undefined' ? performance.now() : 0; +} + +/** + * resume 完成后,判断这次「挂起期间排期」的提示音是否还该播放。纯函数,便于单测: + * - 被更新一轮播放接管 → 不播(让新的那轮来,避免叠音); + * - 等待期间录音已停且已真迟到(超阈值)→ 不播(避免提示音晚到); + * - 其余(含「停了但 resume 很快」的快速录音)→ 照常补播。 + */ +export function shouldPlayDeferredCue(params: { + superseded: boolean; + stoppedWhileWaiting: boolean; + elapsedMs: number; + lateThresholdMs: number; +}): boolean { + if (params.superseded) return false; + if (params.stoppedWhileWaiting && params.elapsedMs > params.lateThresholdMs) return false; + return true; +} function resolveAudioContextCtor(): AudioContextCtor | null { if (typeof window === 'undefined') return null; @@ -73,7 +102,7 @@ function getContext(): AudioContext | null { return sharedCtx; } -// 停掉当前正在发声的节点(不影响 playGeneration —— 仅做去叠音 / 收尾)。 +// 停掉当前正在发声的节点(不动 playSeq / stopSeq —— 仅做去叠音 / 收尾)。 function stopVoices(): void { const ctx = sharedCtx; const now = ctx?.currentTime ?? 0; @@ -90,9 +119,9 @@ function stopVoices(): void { activeVoices = []; } -/** 关闭/停止提示音:停掉在播节点,并作废任何还挂在 resume() 上、尚未排期的播放。 */ +/** 关闭/停止提示音:停掉在播节点,并标记「期间发生过 stop」,供挂起的 resume 回调判定迟到。 */ export function stopAudioCue(): void { - playGeneration++; + stopSeq++; stopVoices(); } @@ -137,6 +166,21 @@ function scheduleCueVoices(ctx: AudioContext): void { } } +/** + * 预热 AudioContext:尽早创建并 resume,让录音真正开始时 ctx 已是 running, + * playRecordStartCue 能同步排期,绕开 suspended→resume 的异步竞态——快速录音丢音的根因。 + * 注:严格 autoplay policy 下无用户手势的 resume 可能被拒(已静默降级),此时预热不生效, + * 退回 playRecordStartCue 里的 playSeq/stopSeq + 迟到阈值兜底。Tauri WebView 多对首次 + * resume 宽松,预热通常能让常见路径走同步分支。胶囊窗口挂载时调用一次即可。 + */ +export function primeAudioCue(): void { + const ctx = getContext(); + if (!ctx) return; + if (ctx.state === 'suspended') { + ctx.resume().catch(() => undefined); + } +} + /** 播放「开始录音」提示音。无 Web Audio 或被挂起且无法恢复时静默降级。 */ export function playRecordStartCue(): void { const ctx = getContext(); @@ -145,14 +189,24 @@ export function playRecordStartCue(): void { // WKWebView / WebView2 的 AudioContext 常处于 suspended:必须先 resume 再排期, // 不能在 resume 未完成时就用冻结的 currentTime 排节点。resume() 失败也不抛(无声降级)。 if (ctx.state === 'suspended') { - const gen = ++playGeneration; + const myPlay = ++playSeq; + const stopAtRequest = stopSeq; + const requestedAt = nowMs(); ctx .resume() .then(() => { - // 等待 resume 期间若已 stopAudioCue(录音结束)或有新一轮播放,本次作废, - // 否则会出现「录音已停,提示音却晚到」。 - if (gen !== playGeneration) return; - scheduleCueVoices(ctx); + // 被更新一轮播放接管就让位;期间录音已停且 resume 真迟到才丢弃; + // 否则照常补响——快速点一下录音(resume 没跑完就已结束)也该有提示音。 + if ( + shouldPlayDeferredCue({ + superseded: myPlay !== playSeq, + stoppedWhileWaiting: stopSeq !== stopAtRequest, + elapsedMs: nowMs() - requestedAt, + lateThresholdMs: DEFERRED_CUE_LATE_THRESHOLD_MS, + }) + ) { + scheduleCueVoices(ctx); + } }) .catch(() => undefined); return;