Merge pull request #560 from Open-Less/feat/audio-cue-on-record

feat(capsule): 录音开始播放合成提示音 + 录音与输入设置开关
This commit is contained in:
TRIP authored and GitHub committed 2026-05-31 14:01:35 +08:00
commit 4bc94ecc5e
13 files changed
+347 -3

No files matched your search

+36
View File
@@ -525,6 +525,11 @@ pub struct UserPreferences {
/// 录音期间临时静音系统输出,停止/取消/出错后恢复原静音状态。
#[serde(default)]
pub mute_during_recording: bool,
/// 按下录音热键进入 recording 状态时,播放一段即时合成的提示音,提醒「已开始录音」。
/// 默认开启;可在「录音与输入」设置里关闭。提示音由 capsule 窗口用 Web Audio API 合成,
/// 不依赖 show_capsule —— 胶囊隐藏时仍会响。
#[serde(default = "default_true")]
pub audio_cue_on_record: bool,
/// 录音输入设备名称。空字符串 = 使用系统默认麦克风。
#[serde(default)]
pub microphone_device_name: String,
@@ -759,6 +764,8 @@ struct UserPreferencesWire {
show_capsule: bool,
#[serde(default)]
mute_during_recording: bool,
#[serde(default = "default_true")]
audio_cue_on_record: bool,
#[serde(default)]
microphone_device_name: String,
active_asr_provider: String,
@@ -842,6 +849,7 @@ impl Default for UserPreferencesWire {
launch_at_login: prefs.launch_at_login,
show_capsule: prefs.show_capsule,
mute_during_recording: prefs.mute_during_recording,
audio_cue_on_record: prefs.audio_cue_on_record,
microphone_device_name: prefs.microphone_device_name,
active_asr_provider: prefs.active_asr_provider,
active_llm_provider: prefs.active_llm_provider,
@@ -920,6 +928,7 @@ impl<'de> Deserialize<'de> for UserPreferences {
launch_at_login: wire.launch_at_login,
show_capsule: wire.show_capsule,
mute_during_recording: wire.mute_during_recording,
audio_cue_on_record: wire.audio_cue_on_record,
microphone_device_name: wire.microphone_device_name,
active_asr_provider: wire.active_asr_provider,
active_llm_provider: wire.active_llm_provider,
@@ -1613,6 +1622,7 @@ impl Default for UserPreferences {
launch_at_login: false,
show_capsule: true,
mute_during_recording: false,
audio_cue_on_record: true,
microphone_device_name: String::new(),
active_asr_provider: default_active_asr_provider(),
active_llm_provider: "ark".into(),
@@ -2254,6 +2264,32 @@ mod tests {
assert!(prefs.allow_non_tsf_insertion_fallback);
}
#[test]
fn missing_audio_cue_on_record_pref_defaults_to_enabled() {
// 老用户的 preferences.json 没有这个字段 → 应默认开启(按下录音即提示)。
let prefs: UserPreferences = serde_json::from_str("{}").unwrap();
assert!(prefs.audio_cue_on_record);
}
#[test]
fn audio_cue_on_record_pref_round_trips_explicit_false() {
// 用户在设置里关掉后,set_settings → 存盘 → get_settings 必须保住 false,
// 否则开关一刷新又跳回 true(字段在 Wire 往返时被丢掉的经典症状)。
let disabled = UserPreferences {
audio_cue_on_record: false,
..Default::default()
};
let json = serde_json::to_string(&disabled).unwrap();
assert!(
json.contains("\"audioCueOnRecord\":false"),
"序列化应输出 camelCase 字段,实际: {json}"
);
let restored: UserPreferences = serde_json::from_str(&json).unwrap();
assert!(!restored.audio_cue_on_record);
}
#[test]
fn missing_custom_style_prompts_defaults_to_empty() {
let prefs: UserPreferences = serde_json::from_str("{}").unwrap();
+51 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { detectOS, type OS } from './WindowChrome';
import {
@@ -6,8 +6,9 @@ import {
getCapsuleMessageLayout,
getCapsulePillMetrics,
} from '../lib/capsuleLayout';
import { invokeOrMock, isTauri } from '../lib/ipc';
import type { CapsulePayload, CapsuleState } from '../lib/types';
import { getSettings, invokeOrMock, isTauri } from '../lib/ipc';
import { playRecordStartCue, stopAudioCue } from '../lib/audioCue';
import type { CapsulePayload, CapsuleState, UserPreferences } from '../lib/types';
interface AudioBarsProps {
level: number;
@@ -307,6 +308,10 @@ export function Capsule() {
const [lastVisibleState, setLastVisibleState] = useState<CapsuleState>(INITIAL_VISIBLE_STATE);
// Windows 端 host 在翻译模式从 84 长到 118;macOS / Linux 上 capsuleLayout 已固定 42 忽略此参数。
const hostMetrics = getCapsuleHostMetrics(os, translation);
// 录音提示音:是否开启(默认 true,老配置缺字段也按开启)+ 上一帧 capsule 状态,
// 用于检测「进入 recording」这条边沿。用 ref 而非 state:提示音是副作用,不该触发重渲染。
const audioCueEnabledRef = useRef<boolean>(true);
const prevStateRef = useRef<CapsuleState>(INITIAL_VISIBLE_STATE);
useEffect(() => {
if (!isTauri) return;
@@ -331,6 +336,49 @@ export function Capsule() {
};
}, []);
// 读取「录音提示音」开关并跟随设置实时更新:capsule 窗口不在 HotkeySettingsProvider 下,
// 所以这里自己拉一次 getSettings(),再订阅 prefs:changed 保持同步。缺字段按默认开启。
useEffect(() => {
if (!isTauri) return;
let cancelled = false;
let unlisten: (() => void) | undefined;
(async () => {
try {
const prefs = await getSettings();
if (!cancelled) audioCueEnabledRef.current = prefs.audioCueOnRecord !== false;
} catch (err) {
console.warn('[capsule] read audioCueOnRecord failed; default on', err);
}
const { listen } = await import('@tauri-apps/api/event');
const handle = await listen<UserPreferences>('prefs:changed', event => {
const next = event.payload;
if (next) audioCueEnabledRef.current = next.audioCueOnRecord !== false;
});
if (cancelled) handle();
else unlisten = handle;
})().catch(err => {
// import / listen 早期失败(Tauri IPC 尚未就绪)不能变成 unhandled rejection。
console.warn('[capsule] audio-cue prefs listener init failed', err);
});
return () => {
cancelled = true;
if (unlisten) unlisten();
};
}, []);
// 提示音触发:检测 capsule 状态进入 recording 的边沿就播放(提醒「已开始录音」);
// 离开 recording 则停掉,避免连按热键时残留尾音。独立于 showCapsule —— 胶囊隐藏也会响。
useEffect(() => {
const prev = prevStateRef.current;
prevStateRef.current = state;
if (!isTauri) return;
if (state === 'recording' && prev !== 'recording') {
if (audioCueEnabledRef.current) playRecordStartCue();
} else if (state !== 'recording' && prev === 'recording') {
stopAudioCue();
}
}, [state]);
// 退出动画调度:在 state 真正进入 idle 时,先用 capsule-out 播放 EXIT_ANIM_MS,再卸载。
// 设计要点:
// 1. 进入非 idle:清掉 leaving,记录最新可见 state;
+3
View File
@@ -544,6 +544,9 @@ export const en: typeof zhCN = {
capsuleDesc: 'Show a translucent capsule at the bottom of the screen while recording.',
muteDuringRecordingLabel: 'Mute while recording',
muteDuringRecordingDesc: 'Temporarily mute system output during voice input to avoid speaker echo.',
audioCueLabel: 'Recording start sound',
audioCueDesc: 'Play a short synthesized chime when you press the hotkey to start recording. Plays even when the capsule is hidden.',
audioCuePreview: 'Preview',
insertGroupTitle: 'Insertion & clipboard',
restoreClipboardLabel: 'Restore clipboard after insert',
restoreClipboardDesc: 'Restore your original clipboard after a successful paste (Windows / Linux only).',
+3
View File
@@ -546,6 +546,9 @@ export const ja: typeof zhCN = {
capsuleDesc: '録音 / 転写中、画面下部に半透明のカプセルを表示。',
muteDuringRecordingLabel: '録音中はミュート',
muteDuringRecordingDesc: '録音中にシステム出力を一時的にミュートし、スピーカーのエコーを防ぎます。',
audioCueLabel: '録音開始音',
audioCueDesc: 'ホットキーで録音を開始するとき、合成した短い通知音を再生します。カプセルが非表示でも鳴ります。',
audioCuePreview: '試聴',
insertGroupTitle: '挿入とクリップボード',
restoreClipboardLabel: '入力後にクリップボードを復元',
restoreClipboardDesc: 'ペースト成功後に元のクリップボード内容を復元(Windows / Linux のみ)。',
+3
View File
@@ -546,6 +546,9 @@ export const ko: typeof zhCN = {
capsuleDesc: '녹음 / 전사 중 화면 하단에 반투명 캡슐을 표시합니다.',
muteDuringRecordingLabel: '녹음 중 음소거',
muteDuringRecordingDesc: '녹음 중 시스템 출력을 일시적으로 음소거하여 스피커 에코를 방지합니다.',
audioCueLabel: '녹음 시작음',
audioCueDesc: '단축키로 녹음을 시작할 때 합성된 짧은 알림음을 재생합니다. 캡슐이 숨겨져 있어도 재생됩니다.',
audioCuePreview: '미리듣기',
insertGroupTitle: '삽입 및 클립보드',
restoreClipboardLabel: '입력 후 클립보드 복원',
restoreClipboardDesc: '붙여넣기 성공 후 원래 클립보드 내용을 복원합니다 (Windows / Linux 만).',
+3
View File
@@ -542,6 +542,9 @@ export const zhCN = {
capsuleDesc: '录音 / 转写时显示屏幕底部胶囊。',
muteDuringRecordingLabel: '录音时静音',
muteDuringRecordingDesc: '录音期间临时静音系统输出,避免扬声器回音。',
audioCueLabel: '录音提示音',
audioCueDesc: '按下热键开始录音时播放一段合成提示音,提醒已开始录音。胶囊隐藏时也会响。',
audioCuePreview: '试听',
insertGroupTitle: '插入与剪贴板',
restoreClipboardLabel: '插入后恢复剪贴板',
restoreClipboardDesc: '粘贴成功后恢复你原来的剪贴板内容(仅 Windows / Linux)。',
+3
View File
@@ -551,6 +551,9 @@ export const zhTW: typeof zhCN = {
capsuleDesc: '錄音 / 轉寫時在屏幕底部顯示半透明膠囊。',
muteDuringRecordingLabel: '錄音時靜音',
muteDuringRecordingDesc: '錄音期間臨時靜音系統輸出,避免揚聲器回音。',
audioCueLabel: '錄音提示音',
audioCueDesc: '按下熱鍵開始錄音時播放一段合成提示音,提醒已開始錄音。膠囊隱藏時也會響。',
audioCuePreview: '試聽',
insertGroupTitle: '插入與剪貼板',
restoreClipboardLabel: '插入後恢復剪貼板',
restoreClipboardDesc: '粘貼成功後恢復你原來的剪貼板內容(僅 Windows / Linux)。',
+49
View File
@@ -0,0 +1,49 @@
// audioCue 纯函数单测,沿用仓库现有 .test.ts 的轻量自执行断言风格
// (无独立 runner —— 在 tsc 类型检查下编译,必要时可用 tsx 直接跑)。
// 播放/停止依赖 Web Audio 运行时,不在此单测覆盖;这里只钉住可被回归的音符规划。
import { cueTotalDurationMs, recordStartCueTones, type CueTone } from './audioCue';
function assert(cond: boolean, name: string) {
if (!cond) throw new Error(`assertion failed: ${name}`);
}
function assertEqual<T>(actual: T, expected: T, name: string) {
if (actual !== expected) {
throw new Error(`${name}: expected ${String(expected)}, got ${String(actual)}`);
}
}
{
const tones = recordStartCueTones();
assertEqual(tones.length, 2, 'start cue is a two-tone chime');
assert(
tones.every(t => t.freq > 0 && t.durationMs > 0),
'every tone has positive frequency and duration',
);
// 指数包络 ramp 不能到 0,峰值必须严格为正,否则 exponentialRampToValueAtTime 抛错。
assert(
tones.every(t => t.peakGain > 0 && t.peakGain <= 1),
'every tone peak gain is within (0, 1]',
);
// 第二个音上升(小三度),听感是「叮咚」而非平铺两声。
assert(tones[1].freq > tones[0].freq, 'second tone rises in pitch');
// 两音交叠:第二个音在第一个音结束前起,连成一段。
assert(
tones[1].startMs < tones[0].startMs + tones[0].durationMs,
'tones overlap into a single chime',
);
}
{
const flat: CueTone[] = [
{ freq: 440, startMs: 0, durationMs: 100, peakGain: 0.2 },
{ freq: 880, startMs: 90, durationMs: 170, peakGain: 0.2 },
];
assertEqual(cueTotalDurationMs(flat), 260, 'total duration is last tone end (90 + 170)');
assertEqual(cueTotalDurationMs([]), 0, 'empty cue has zero duration');
assert(cueTotalDurationMs(recordStartCueTones()) > 0, 'start cue has positive total duration');
}
// 静默成功难以与「没跑」区分;直接 tsx 跑时给个明确通过信号。
console.log('[audioCue.test] all assertions passed');
+162
View File
@@ -0,0 +1,162 @@
// 录音提示音:用 Web Audio API 即时「合成」一段短促上升双音,不打包任何音频文件。
// 提供两个操作:
// - playRecordStartCue() 播放(按下录音热键、进入 recording 状态时调用)
// - stopAudioCue() 关闭/停止(离开 recording、或连按热键避免叠音时调用)
//
// 触发点在 capsule 窗口(始终存活、收到 capsule:state 事件);设置页「试听」也复用同一份。
// 设计原则:任何环境(无 Web Audio、AudioContext 被自动播放策略挂起、单音创建失败)都
// 静默降级,绝不抛错影响录音主流程。
/** 单个正弦音符的合成参数(相对提示音起点)。 */
export interface CueTone {
/** 频率 (Hz)。 */
freq: number;
/** 相对提示音起点的开始时间 (ms)。 */
startMs: number;
/** 持续时长 (ms)。 */
durationMs: number;
/** 指数包络峰值增益 (0..1),控制响度。 */
peakGain: number;
}
// 上升小三度双音 (A5 880Hz → C#6 1108.73Hz):给「开始录音」一个明确、轻快、不刺耳的听感。
// 两个音轻微交叠,听感连贯成一个「叮咚」而非两声独立 beep。纯数据 → 便于单测。
export function recordStartCueTones(): CueTone[] {
return [
{ freq: 880, startMs: 0, durationMs: 130, peakGain: 0.16 },
{ freq: 1108.73, startMs: 95, durationMs: 170, peakGain: 0.18 },
];
}
/** 提示音总时长 (ms) = 最后一个音的结束时刻。供调用方排期 stop / 试听反馈用。 */
export function cueTotalDurationMs(tones: CueTone[]): number {
return tones.reduce((max, t) => Math.max(max, t.startMs + t.durationMs), 0);
}
// Safari/WKWebView 旧前缀;用结构化类型而非 any 拿到 webkit 兜底构造器。
type AudioContextCtor = typeof AudioContext;
interface WebkitWindow {
webkitAudioContext?: AudioContextCtor;
}
// 模块级单例。Tauri 每个窗口是独立 webview = 独立 JS 模块实例,所以 capsule 窗口与
// 设置窗口各自持有一份 ctx / activeVoices,不会互相打架。
let sharedCtx: AudioContext | null = null;
interface ActiveVoice {
osc: OscillatorNode;
gain: GainNode;
}
let activeVoices: ActiveVoice[] = [];
// 每次「关闭」或「新一轮播放」自增。suspended 时 play 会等 resume() 再排期,
// 这个代号让挂起的 resume 回调能判断「等待期间是否已被叫停/被新一轮取代」,
// 避免录音已经结束、提示音却姗姗来迟地响起来(冷启动 WebView 上快按热键可复现)。
let playGeneration = 0;
function resolveAudioContextCtor(): AudioContextCtor | null {
if (typeof window === 'undefined') return null;
// window.AudioContext 来自全局声明;webkit 前缀单独用结构化类型拿,避免 any。
const webkit = window as WebkitWindow;
return window.AudioContext ?? webkit.webkitAudioContext ?? null;
}
function getContext(): AudioContext | null {
const Ctor = resolveAudioContextCtor();
if (!Ctor) return null;
if (!sharedCtx) {
try {
sharedCtx = new Ctor();
} catch {
sharedCtx = null;
return null;
}
}
return sharedCtx;
}
// 停掉当前正在发声的节点(不影响 playGeneration —— 仅做去叠音 / 收尾)。
function stopVoices(): void {
const ctx = sharedCtx;
const now = ctx?.currentTime ?? 0;
for (const { osc, gain } of activeVoices) {
try {
gain.gain.cancelScheduledValues(now);
// 指数 ramp 不能到 0,用极小值做近似静音后立即停振。
gain.gain.setValueAtTime(0.0001, now);
osc.stop(now + 0.02);
} catch {
// 已停止 / 已断开,忽略。
}
}
activeVoices = [];
}
/** 关闭/停止提示音:停掉在播节点,并作废任何还挂在 resume() 上、尚未排期的播放。 */
export function stopAudioCue(): void {
playGeneration++;
stopVoices();
}
// 实际排期合成节点。必须在 AudioContext 处于 running(非 suspended)时调用:
// suspended 时 currentTime 冻结在暂停时刻,节点会排到过期时间点 → 不发声还堆积。
function scheduleCueVoices(ctx: AudioContext): void {
// 连按热键时先停掉上一轮,避免叠音越来越响。用 stopVoices 而非 stopAudioCue:
// 这里不该作废自己这一轮的 generation。
stopVoices();
const base = ctx.currentTime + 0.01;
for (const tone of recordStartCueTones()) {
try {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
const t0 = base + tone.startMs / 1000;
const tEnd = t0 + tone.durationMs / 1000;
osc.frequency.setValueAtTime(tone.freq, t0);
// 5ms attack + 指数 release:避免起停的 click 爆音。
gain.gain.setValueAtTime(0.0001, t0);
gain.gain.exponentialRampToValueAtTime(tone.peakGain, t0 + 0.005);
gain.gain.exponentialRampToValueAtTime(0.0001, tEnd);
osc.connect(gain).connect(ctx.destination);
osc.start(t0);
osc.stop(tEnd + 0.02);
const voice: ActiveVoice = { osc, gain };
activeVoices.push(voice);
osc.onended = () => {
activeVoices = activeVoices.filter(v => v !== voice);
try {
osc.disconnect();
gain.disconnect();
} catch {
// noop
}
};
} catch {
// 单个音创建/排期失败不影响其余音。
}
}
}
/** 播放「开始录音」提示音。无 Web Audio 或被挂起且无法恢复时静默降级。 */
export function playRecordStartCue(): void {
const ctx = getContext();
if (!ctx) return;
// WKWebView / WebView2 的 AudioContext 常处于 suspended:必须先 resume 再排期,
// 不能在 resume 未完成时就用冻结的 currentTime 排节点。resume() 失败也不抛(无声降级)。
if (ctx.state === 'suspended') {
const gen = ++playGeneration;
ctx
.resume()
.then(() => {
// 等待 resume 期间若已 stopAudioCue(录音结束)或有新一轮播放,本次作废,
// 否则会出现「录音已停,提示音却晚到」。
if (gen !== playGeneration) return;
scheduleCueVoices(ctx);
})
.catch(() => undefined);
return;
}
scheduleCueVoices(ctx);
}
+1
View File
@@ -79,6 +79,7 @@ let mockSettings: UserPreferences = {
launchAtLogin: false,
showCapsule: true,
muteDuringRecording: false,
audioCueOnRecord: true,
microphoneDeviceName: "",
activeAsrProvider: "foundry-local-whisper",
activeLlmProvider: "ark",
@@ -31,6 +31,7 @@ const previousPrefs: UserPreferences = {
launchAtLogin: false,
showCapsule: true,
muteDuringRecording: false,
audioCueOnRecord: true,
microphoneDeviceName: '',
activeAsrProvider: 'volcengine',
activeLlmProvider: 'ark',
+3
View File
@@ -218,6 +218,9 @@ export interface UserPreferences {
showCapsule: boolean;
/** 录音期间临时静音系统输出,停止/取消/出错后恢复原静音状态。 */
muteDuringRecording: boolean;
/** 按下录音热键进入 recording 状态时,播放一段合成提示音提醒「已开始录音」。
* 默认开启;在 capsule 窗口用 Web Audio API 合成,不依赖 showCapsule。 */
audioCueOnRecord: boolean;
/** 录音输入设备名称。空字符串 = 使用系统默认麦克风。 */
microphoneDeviceName: string;
activeAsrProvider: string;
@@ -5,6 +5,7 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ShortcutRecorder } from '../../components/ShortcutRecorder';
import { playRecordStartCue } from '../../lib/audioCue';
import { isHotkeyModeMigrationNoticeActive } from '../../lib/hotkeyMigration';
import {
isTauri,
@@ -109,6 +110,8 @@ export function RecordingInputSection() {
savePrefs({ ...prefs, showCapsule });
const onMuteDuringRecordingChange = (muteDuringRecording: boolean) =>
savePrefs({ ...prefs, muteDuringRecording });
const onAudioCueChange = (audioCueOnRecord: boolean) =>
savePrefs({ ...prefs, audioCueOnRecord });
const onMicrophoneDeviceChange = (microphoneDeviceName: string) =>
savePrefs({ ...prefs, microphoneDeviceName });
const onRestoreClipboardChange = (restoreClipboardAfterPaste: boolean) =>
@@ -215,6 +218,32 @@ export function RecordingInputSection() {
<SettingRow label={t('settings.recording.muteDuringRecordingLabel')}>
<Toggle on={prefs.muteDuringRecording} onToggle={onMuteDuringRecordingChange} />
</SettingRow>
<SettingRow
label={t('settings.recording.audioCueLabel')}
desc={t('settings.recording.audioCueDesc')}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Toggle on={prefs.audioCueOnRecord} onToggle={onAudioCueChange} />
<button
type="button"
onClick={() => playRecordStartCue()}
style={{
padding: '5px 12px',
fontSize: 12,
fontWeight: 500,
fontFamily: 'inherit',
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 8,
background: 'var(--ol-surface-2)',
color: 'var(--ol-ink-2)',
cursor: 'default',
transition: 'background 0.16s var(--ol-motion-quick)',
}}
>
{t('settings.recording.audioCuePreview')}
</button>
</div>
</SettingRow>
{os === 'linux' && (
<SettingRow label={t('settings.advanced.streamingInsertLabel')}>
<Toggle