Merge pull request #584 from Open-Less/fix/audio-cue-missing
fix(audioCue): 修复快速录音时录音提示音偶尔丢失
This commit is contained in:
3 files changed
+138
-14
No files matched your search
@@ -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(() => {
|
||||
|
||||
@@ -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<T>(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');
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user