diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 93b0ae08..7c0bfdc2 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -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(); diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index f848adb7..31f5781c 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -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(INITIAL_VISIBLE_STATE); // Windows 端 host 在翻译模式从 84 长到 118;macOS / Linux 上 capsuleLayout 已固定 42 忽略此参数。 const hostMetrics = getCapsuleHostMetrics(os, translation); + // 录音提示音:是否开启(默认 true,老配置缺字段也按开启)+ 上一帧 capsule 状态, + // 用于检测「进入 recording」这条边沿。用 ref 而非 state:提示音是副作用,不该触发重渲染。 + const audioCueEnabledRef = useRef(true); + const prevStateRef = useRef(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('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; diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index e70a4d76..f7270eee 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -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).', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 3a26c587..bef10449 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -546,6 +546,9 @@ export const ja: typeof zhCN = { capsuleDesc: '録音 / 転写中、画面下部に半透明のカプセルを表示。', muteDuringRecordingLabel: '録音中はミュート', muteDuringRecordingDesc: '録音中にシステム出力を一時的にミュートし、スピーカーのエコーを防ぎます。', + audioCueLabel: '録音開始音', + audioCueDesc: 'ホットキーで録音を開始するとき、合成した短い通知音を再生します。カプセルが非表示でも鳴ります。', + audioCuePreview: '試聴', insertGroupTitle: '挿入とクリップボード', restoreClipboardLabel: '入力後にクリップボードを復元', restoreClipboardDesc: 'ペースト成功後に元のクリップボード内容を復元(Windows / Linux のみ)。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 85c87722..13df258e 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -546,6 +546,9 @@ export const ko: typeof zhCN = { capsuleDesc: '녹음 / 전사 중 화면 하단에 반투명 캡슐을 표시합니다.', muteDuringRecordingLabel: '녹음 중 음소거', muteDuringRecordingDesc: '녹음 중 시스템 출력을 일시적으로 음소거하여 스피커 에코를 방지합니다.', + audioCueLabel: '녹음 시작음', + audioCueDesc: '단축키로 녹음을 시작할 때 합성된 짧은 알림음을 재생합니다. 캡슐이 숨겨져 있어도 재생됩니다.', + audioCuePreview: '미리듣기', insertGroupTitle: '삽입 및 클립보드', restoreClipboardLabel: '입력 후 클립보드 복원', restoreClipboardDesc: '붙여넣기 성공 후 원래 클립보드 내용을 복원합니다 (Windows / Linux 만).', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index e94e4035..9b82a96f 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -542,6 +542,9 @@ export const zhCN = { capsuleDesc: '录音 / 转写时显示屏幕底部胶囊。', muteDuringRecordingLabel: '录音时静音', muteDuringRecordingDesc: '录音期间临时静音系统输出,避免扬声器回音。', + audioCueLabel: '录音提示音', + audioCueDesc: '按下热键开始录音时播放一段合成提示音,提醒已开始录音。胶囊隐藏时也会响。', + audioCuePreview: '试听', insertGroupTitle: '插入与剪贴板', restoreClipboardLabel: '插入后恢复剪贴板', restoreClipboardDesc: '粘贴成功后恢复你原来的剪贴板内容(仅 Windows / Linux)。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index dd99c546..8fc2382d 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -551,6 +551,9 @@ export const zhTW: typeof zhCN = { capsuleDesc: '錄音 / 轉寫時在屏幕底部顯示半透明膠囊。', muteDuringRecordingLabel: '錄音時靜音', muteDuringRecordingDesc: '錄音期間臨時靜音系統輸出,避免揚聲器回音。', + audioCueLabel: '錄音提示音', + audioCueDesc: '按下熱鍵開始錄音時播放一段合成提示音,提醒已開始錄音。膠囊隱藏時也會響。', + audioCuePreview: '試聽', insertGroupTitle: '插入與剪貼板', restoreClipboardLabel: '插入後恢復剪貼板', restoreClipboardDesc: '粘貼成功後恢復你原來的剪貼板內容(僅 Windows / Linux)。', diff --git a/openless-all/app/src/lib/audioCue.test.ts b/openless-all/app/src/lib/audioCue.test.ts new file mode 100644 index 00000000..5c6782de --- /dev/null +++ b/openless-all/app/src/lib/audioCue.test.ts @@ -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(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'); diff --git a/openless-all/app/src/lib/audioCue.ts b/openless-all/app/src/lib/audioCue.ts new file mode 100644 index 00000000..ef7a79f8 --- /dev/null +++ b/openless-all/app/src/lib/audioCue.ts @@ -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); +} diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 7491d6ff..a146238c 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -79,6 +79,7 @@ let mockSettings: UserPreferences = { launchAtLogin: false, showCapsule: true, muteDuringRecording: false, + audioCueOnRecord: true, microphoneDeviceName: "", activeAsrProvider: "foundry-local-whisper", activeLlmProvider: "ark", diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index 52154120..66d08b68 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -31,6 +31,7 @@ const previousPrefs: UserPreferences = { launchAtLogin: false, showCapsule: true, muteDuringRecording: false, + audioCueOnRecord: true, microphoneDeviceName: '', activeAsrProvider: 'volcengine', activeLlmProvider: 'ark', diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index be033bf7..9b466cdf 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -218,6 +218,9 @@ export interface UserPreferences { showCapsule: boolean; /** 录音期间临时静音系统输出,停止/取消/出错后恢复原静音状态。 */ muteDuringRecording: boolean; + /** 按下录音热键进入 recording 状态时,播放一段合成提示音提醒「已开始录音」。 + * 默认开启;在 capsule 窗口用 Web Audio API 合成,不依赖 showCapsule。 */ + audioCueOnRecord: boolean; /** 录音输入设备名称。空字符串 = 使用系统默认麦克风。 */ microphoneDeviceName: string; activeAsrProvider: string; diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index d397a137..3ee43c48 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -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() { + +
+ + +
+
{os === 'linux' && (