Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions openless-all/app/src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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();
Expand Down
54 changes: 51 additions & 3 deletions openless-all/app/src/components/Capsule.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { detectOS, type OS } from './WindowChrome';
import {
getCapsuleHostMetrics,
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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).',
Expand Down
3 changes: 3 additions & 0 deletions openless-all/app/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,9 @@ export const ja: typeof zhCN = {
capsuleDesc: '録音 / 転写中、画面下部に半透明のカプセルを表示。',
muteDuringRecordingLabel: '録音中はミュート',
muteDuringRecordingDesc: '録音中にシステム出力を一時的にミュートし、スピーカーのエコーを防ぎます。',
audioCueLabel: '録音開始音',
audioCueDesc: 'ホットキーで録音を開始するとき、合成した短い通知音を再生します。カプセルが非表示でも鳴ります。',
audioCuePreview: '試聴',
insertGroupTitle: '挿入とクリップボード',
restoreClipboardLabel: '入力後にクリップボードを復元',
restoreClipboardDesc: 'ペースト成功後に元のクリップボード内容を復元(Windows / Linux のみ)。',
Expand Down
3 changes: 3 additions & 0 deletions openless-all/app/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,9 @@ export const ko: typeof zhCN = {
capsuleDesc: '녹음 / 전사 중 화면 하단에 반투명 캡슐을 표시합니다.',
muteDuringRecordingLabel: '녹음 중 음소거',
muteDuringRecordingDesc: '녹음 중 시스템 출력을 일시적으로 음소거하여 스피커 에코를 방지합니다.',
audioCueLabel: '녹음 시작음',
audioCueDesc: '단축키로 녹음을 시작할 때 합성된 짧은 알림음을 재생합니다. 캡슐이 숨겨져 있어도 재생됩니다.',
audioCuePreview: '미리듣기',
insertGroupTitle: '삽입 및 클립보드',
restoreClipboardLabel: '입력 후 클립보드 복원',
restoreClipboardDesc: '붙여넣기 성공 후 원래 클립보드 내용을 복원합니다 (Windows / Linux 만).',
Expand Down
3 changes: 3 additions & 0 deletions openless-all/app/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,9 @@ export const zhCN = {
capsuleDesc: '录音 / 转写时显示屏幕底部胶囊。',
muteDuringRecordingLabel: '录音时静音',
muteDuringRecordingDesc: '录音期间临时静音系统输出,避免扬声器回音。',
audioCueLabel: '录音提示音',
audioCueDesc: '按下热键开始录音时播放一段合成提示音,提醒已开始录音。胶囊隐藏时也会响。',
audioCuePreview: '试听',
insertGroupTitle: '插入与剪贴板',
restoreClipboardLabel: '插入后恢复剪贴板',
restoreClipboardDesc: '粘贴成功后恢复你原来的剪贴板内容(仅 Windows / Linux)。',
Expand Down
3 changes: 3 additions & 0 deletions openless-all/app/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,9 @@ export const zhTW: typeof zhCN = {
capsuleDesc: '錄音 / 轉寫時在屏幕底部顯示半透明膠囊。',
muteDuringRecordingLabel: '錄音時靜音',
muteDuringRecordingDesc: '錄音期間臨時靜音系統輸出,避免揚聲器回音。',
audioCueLabel: '錄音提示音',
audioCueDesc: '按下熱鍵開始錄音時播放一段合成提示音,提醒已開始錄音。膠囊隱藏時也會響。',
audioCuePreview: '試聽',
insertGroupTitle: '插入與剪貼板',
restoreClipboardLabel: '插入後恢復剪貼板',
restoreClipboardDesc: '粘貼成功後恢復你原來的剪貼板內容(僅 Windows / Linux)。',
Expand Down
49 changes: 49 additions & 0 deletions openless-all/app/src/lib/audioCue.test.ts
Original file line number Diff line number Diff line change
@@ -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');
Loading
Loading