Merge pull request #560 from Open-Less/feat/audio-cue-on-record
feat(capsule): 录音开始播放合成提示音 + 录音与输入设置开关
This commit is contained in:
13 files changed
+347
-3
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).',
|
||||
|
||||
@@ -546,6 +546,9 @@ export const ja: typeof zhCN = {
|
||||
capsuleDesc: '録音 / 転写中、画面下部に半透明のカプセルを表示。',
|
||||
muteDuringRecordingLabel: '録音中はミュート',
|
||||
muteDuringRecordingDesc: '録音中にシステム出力を一時的にミュートし、スピーカーのエコーを防ぎます。',
|
||||
audioCueLabel: '録音開始音',
|
||||
audioCueDesc: 'ホットキーで録音を開始するとき、合成した短い通知音を再生します。カプセルが非表示でも鳴ります。',
|
||||
audioCuePreview: '試聴',
|
||||
insertGroupTitle: '挿入とクリップボード',
|
||||
restoreClipboardLabel: '入力後にクリップボードを復元',
|
||||
restoreClipboardDesc: 'ペースト成功後に元のクリップボード内容を復元(Windows / Linux のみ)。',
|
||||
|
||||
@@ -546,6 +546,9 @@ export const ko: typeof zhCN = {
|
||||
capsuleDesc: '녹음 / 전사 중 화면 하단에 반투명 캡슐을 표시합니다.',
|
||||
muteDuringRecordingLabel: '녹음 중 음소거',
|
||||
muteDuringRecordingDesc: '녹음 중 시스템 출력을 일시적으로 음소거하여 스피커 에코를 방지합니다.',
|
||||
audioCueLabel: '녹음 시작음',
|
||||
audioCueDesc: '단축키로 녹음을 시작할 때 합성된 짧은 알림음을 재생합니다. 캡슐이 숨겨져 있어도 재생됩니다.',
|
||||
audioCuePreview: '미리듣기',
|
||||
insertGroupTitle: '삽입 및 클립보드',
|
||||
restoreClipboardLabel: '입력 후 클립보드 복원',
|
||||
restoreClipboardDesc: '붙여넣기 성공 후 원래 클립보드 내용을 복원합니다 (Windows / Linux 만).',
|
||||
|
||||
@@ -542,6 +542,9 @@ export const zhCN = {
|
||||
capsuleDesc: '录音 / 转写时显示屏幕底部胶囊。',
|
||||
muteDuringRecordingLabel: '录音时静音',
|
||||
muteDuringRecordingDesc: '录音期间临时静音系统输出,避免扬声器回音。',
|
||||
audioCueLabel: '录音提示音',
|
||||
audioCueDesc: '按下热键开始录音时播放一段合成提示音,提醒已开始录音。胶囊隐藏时也会响。',
|
||||
audioCuePreview: '试听',
|
||||
insertGroupTitle: '插入与剪贴板',
|
||||
restoreClipboardLabel: '插入后恢复剪贴板',
|
||||
restoreClipboardDesc: '粘贴成功后恢复你原来的剪贴板内容(仅 Windows / Linux)。',
|
||||
|
||||
@@ -551,6 +551,9 @@ export const zhTW: typeof zhCN = {
|
||||
capsuleDesc: '錄音 / 轉寫時在屏幕底部顯示半透明膠囊。',
|
||||
muteDuringRecordingLabel: '錄音時靜音',
|
||||
muteDuringRecordingDesc: '錄音期間臨時靜音系統輸出,避免揚聲器回音。',
|
||||
audioCueLabel: '錄音提示音',
|
||||
audioCueDesc: '按下熱鍵開始錄音時播放一段合成提示音,提醒已開始錄音。膠囊隱藏時也會響。',
|
||||
audioCuePreview: '試聽',
|
||||
insertGroupTitle: '插入與剪貼板',
|
||||
restoreClipboardLabel: '插入後恢復剪貼板',
|
||||
restoreClipboardDesc: '粘貼成功後恢復你原來的剪貼板內容(僅 Windows / Linux)。',
|
||||
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user