Skip to content
Merged
32 changes: 32 additions & 0 deletions app/src/components/settings/panels/VoiceDebugPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ const VoiceDebugPanel = () => {
min_duration_secs: settings.min_duration_secs,
silence_threshold: settings.silence_threshold,
custom_dictionary: settings.custom_dictionary,
always_on_enabled: settings.always_on_enabled,
});
setNotice(t('voice.debug.settingsSaved'));
await loadData(true);
Expand Down Expand Up @@ -203,6 +204,37 @@ const VoiceDebugPanel = () => {

{settings && (
<>
{/* Always-on listening (Phase 2) — opt-in, privacy-sensitive. */}
<div className="flex items-start justify-between gap-3 rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2.5">
<div className="min-w-0">
<span className="text-xs font-medium text-stone-700 dark:text-neutral-200">
{t('voice.debug.alwaysOn')}
</span>
<p className="text-[11px] text-stone-400 dark:text-neutral-500 mt-0.5">
{t('voice.debug.alwaysOnDesc')}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={settings.always_on_enabled}
aria-label={t('voice.debug.alwaysOn')}
data-testid="voice-always-on-toggle"
onClick={() => updateSetting('always_on_enabled', !settings.always_on_enabled)}
className={`relative inline-flex h-4 w-7 shrink-0 items-center rounded-full transition-colors ${
settings.always_on_enabled
? 'bg-primary-500'
: 'bg-stone-300 dark:bg-neutral-600'
}`}>
<span
aria-hidden
className={`inline-block h-3 w-3 transform rounded-full bg-white shadow transition-transform ${
settings.always_on_enabled ? 'translate-x-3.5' : 'translate-x-0.5'
}`}
/>
</button>
</div>

<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('voice.debug.minimumRecordingSeconds')}
Expand Down
52 changes: 52 additions & 0 deletions app/src/components/settings/panels/VoicePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from '../../../services/api/voiceSettingsApi';
import {
openhumanGetVoiceServerSettings,
openhumanUpdateVoiceServerSettings,
openhumanVoiceSetProviders,
openhumanVoiceStatus,
type VoiceProvidersSnapshot,
Expand Down Expand Up @@ -485,6 +486,57 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
)}

<div className={embedded ? 'space-y-4' : 'p-4 space-y-4'}>
{/* ─── Always-on listening (Phase 2) ──────────────────────────── */}
{settings && (
<section className="space-y-3">
<div className="bg-stone-50 dark:bg-neutral-800/60 rounded-lg border border-stone-200 dark:border-neutral-800 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('voice.debug.alwaysOn')}
</h3>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1">
{t('voice.debug.alwaysOnDesc')}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={settings.always_on_enabled}
aria-label={t('voice.debug.alwaysOn')}
data-testid="voice-always-on-toggle"
onClick={async () => {
const next = !settings.always_on_enabled;
setSettings(current =>
current ? { ...current, always_on_enabled: next } : current
);
try {
await openhumanUpdateVoiceServerSettings({ always_on_enabled: next });
} catch (err) {
// Revert on failure so the UI reflects the persisted value.
setSettings(current =>
current ? { ...current, always_on_enabled: !next } : current
);
console.error('[VoicePanel] failed to toggle always-on', err);
}
Comment thread
M3gA-Mind marked this conversation as resolved.
}}
className={`relative mt-0.5 inline-flex h-4 w-7 shrink-0 items-center rounded-full transition-colors ${
settings.always_on_enabled
? 'bg-primary-500'
: 'bg-stone-300 dark:bg-neutral-600'
}`}>
<span
aria-hidden
className={`inline-block h-3 w-3 transform rounded-full bg-white shadow transition-transform ${
settings.always_on_enabled ? 'translate-x-3.5' : 'translate-x-0.5'
}`}
/>
</button>
</div>
</div>
</section>
)}

{/* ─── Section 1: Voice Provider Chips ─────────────────────────── */}
<section className="space-y-3">
<div
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import {
openhumanGetVoiceServerSettings,
openhumanUpdateVoiceServerSettings,
openhumanVoiceServerStatus,
openhumanVoiceStatus,
type VoiceServerSettings,
type VoiceServerStatus,
type VoiceStatus,
} from '../../../../utils/tauriCommands';
import type { ConfigSnapshot } from '../../../../utils/tauriCommands/config';
import VoiceDebugPanel from '../VoiceDebugPanel';

// Key-passthrough i18n + trivial chrome so we can render the panel standalone.
vi.mock('../../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));
vi.mock('../../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }),
}));
vi.mock('../components/SettingsHeader', () => ({ default: () => null }));

vi.mock('../../../../utils/tauriCommands', () => ({
openhumanGetVoiceServerSettings: vi.fn(),
openhumanUpdateVoiceServerSettings: vi.fn(),
openhumanVoiceServerStatus: vi.fn(),
openhumanVoiceStatus: vi.fn(),
}));

const SETTINGS: VoiceServerSettings = {
auto_start: false,
hotkey: 'Fn',
activation_mode: 'push',
skip_cleanup: true,
min_duration_secs: 0.3,
silence_threshold: 0.002,
custom_dictionary: [],
always_on_enabled: false,
};

const SERVER_STATUS: VoiceServerStatus = {
state: 'idle',
hotkey: 'Fn',
activation_mode: 'push',
transcription_count: 0,
last_error: null,
};

const VOICE_STATUS: VoiceStatus = {
stt_available: true,
tts_available: true,
stt_model_id: 'ggml-tiny',
tts_voice_id: 'en_US',
whisper_binary: null,
piper_binary: null,
stt_model_path: null,
tts_voice_path: null,
whisper_in_process: true,
llm_cleanup_enabled: true,
stt_provider: 'cloud',
tts_provider: 'cloud',
};

describe('VoiceDebugPanel — always-on toggle', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(openhumanGetVoiceServerSettings).mockResolvedValue({
result: { ...SETTINGS },
logs: [],
});
vi.mocked(openhumanUpdateVoiceServerSettings).mockResolvedValue({
result: {} as unknown as ConfigSnapshot,
logs: [],
});
vi.mocked(openhumanVoiceServerStatus).mockResolvedValue(SERVER_STATUS);
vi.mocked(openhumanVoiceStatus).mockResolvedValue(VOICE_STATUS);
});

it('toggles always-on and persists it via the update RPC on save', async () => {
render(<VoiceDebugPanel />);

const toggle = await screen.findByTestId('voice-always-on-toggle');
expect(toggle).toHaveAttribute('aria-checked', 'false');

// Local optimistic flip (creates an unsaved change → enables Save).
fireEvent.click(toggle);
expect(toggle).toHaveAttribute('aria-checked', 'true');

fireEvent.click(screen.getByText('common.save'));

await waitFor(() =>
expect(vi.mocked(openhumanUpdateVoiceServerSettings)).toHaveBeenCalledWith(
expect.objectContaining({ always_on_enabled: true })
)
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,18 @@ import {
import { renderWithProviders } from '../../../../test/test-utils';
import {
openhumanGetVoiceServerSettings,
openhumanUpdateVoiceServerSettings,
openhumanVoiceSetProviders,
openhumanVoiceStatus,
type VoiceServerSettings,
type VoiceStatus,
} from '../../../../utils/tauriCommands';
import type { ConfigSnapshot } from '../../../../utils/tauriCommands/config';
import VoicePanel from '../VoicePanel';

vi.mock('../../../../utils/tauriCommands', () => ({
openhumanGetVoiceServerSettings: vi.fn(),
openhumanUpdateVoiceServerSettings: vi.fn(),
openhumanVoiceSetProviders: vi.fn(),
openhumanVoiceStatus: vi.fn(),
}));
Expand Down Expand Up @@ -111,6 +114,7 @@ describe('VoicePanel', () => {
min_duration_secs: 0.3,
silence_threshold: 0.002,
custom_dictionary: [],
always_on_enabled: false,
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
voiceStatus: {
stt_available: true,
Expand All @@ -135,6 +139,12 @@ describe('VoicePanel', () => {
result: { ...runtime.settings },
logs: [],
}));
// The panel ignores the snapshot it returns; a minimal cast keeps the
// mock typed without constructing a full ConfigSnapshot.
vi.mocked(openhumanUpdateVoiceServerSettings).mockImplementation(async () => ({
result: {} as unknown as ConfigSnapshot,
logs: [],
}));
vi.mocked(openhumanVoiceStatus).mockImplementation(async () => ({ ...runtime.voiceStatus }));
vi.mocked(openhumanVoiceSetProviders).mockImplementation(async update => {
if (update.stt_provider) runtime.voiceStatus.stt_provider = update.stt_provider;
Expand Down Expand Up @@ -496,4 +506,36 @@ describe('VoicePanel', () => {

await waitFor(() => expect(screen.getByText('core offline')).toBeInTheDocument());
});

// ─── Always-on listening toggle (Phase 2) ───────────────────────────────

it('persists the always-on toggle and flips aria-checked on click', async () => {
renderWithProviders(<VoicePanel />, { initialEntries: ['/settings/voice'] });

const toggle = await screen.findByTestId('voice-always-on-toggle');
expect(toggle).toHaveAttribute('aria-checked', 'false');

fireEvent.click(toggle);

await waitFor(() =>
expect(vi.mocked(openhumanUpdateVoiceServerSettings)).toHaveBeenCalledWith(
expect.objectContaining({ always_on_enabled: true })
)
);
// Optimistic update reflects immediately.
expect(toggle).toHaveAttribute('aria-checked', 'true');
});

it('reverts the toggle when the update RPC rejects', async () => {
vi.mocked(openhumanUpdateVoiceServerSettings).mockRejectedValueOnce(new Error('rpc down'));

renderWithProviders(<VoicePanel />, { initialEntries: ['/settings/voice'] });

const toggle = await screen.findByTestId('voice-always-on-toggle');
fireEvent.click(toggle);

// Optimistic on → then reverted back to off after the failure.
await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'false'));
expect(vi.mocked(openhumanUpdateVoiceServerSettings)).toHaveBeenCalledTimes(1);
});
});
10 changes: 10 additions & 0 deletions app/src/lib/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1390,6 +1390,9 @@ const messages: TranslationMap = {
'voice.debug.silenceThreshold': 'عتبة الصمت (RMS)',
'voice.debug.silenceThresholdDesc':
'تُعامَل التسجيلات ذات الطاقة الأدنى من هذا الحد كصمت ويُتخطى فيها. كلما كانت القيمة أصغر، كان النظام أكثر حساسية.',
'voice.debug.alwaysOn': 'الاستماع الدائم',
'voice.debug.alwaysOnDesc':
'أبقِ الميكروفون مفتوحًا وأرسل ما تقوله إلى الوكيل تلقائيًا دون مفتاح اختصار. يتوقف مؤقتًا عند قفل الشاشة.',
'voice.providers.saved': 'تم حفظ موفري الصوت.',
'voice.providers.failedToSave': 'فشل في حفظ موفري الصوت',
'voice.providers.ellipsis': '…',
Expand Down Expand Up @@ -4719,6 +4722,13 @@ const messages: TranslationMap = {
'runQueue.collectHint': 'إضافة كسياق إضافي',
'runQueue.status': '{total} في الانتظار',
'runQueue.cleared': 'تم مسح قائمة الانتظار',
'notch.ready': 'جاهز',
'notch.processing': 'جارٍ المعالجة…',
'notch.listening': 'أستمع…',
'notch.thinking': 'أفكر…',
'notch.speaking': 'أتحدث…',
'notch.transcribing': 'أفسّر…',
'notch.executing': 'أنفّذ…',
};

export default messages;
10 changes: 10 additions & 0 deletions app/src/lib/i18n/bn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1418,6 +1418,9 @@ const messages: TranslationMap = {
'voice.debug.silenceThreshold': 'সাইলেন্স থ্রেশহোল্ড (RMS)',
'voice.debug.silenceThresholdDesc':
'এই মানের নিচে শক্তির রেকর্ডিংগুলি নীরবতা হিসেবে গণ্য হয় এবং এড়িয়ে যাওয়া হয়। কম মান = আরও সংবেদনশীল।',
'voice.debug.alwaysOn': 'সবসময় শোনা',
'voice.debug.alwaysOnDesc':
'মাইক্রোফোন খোলা রাখুন এবং আপনি যা বলেন তা হটকি ছাড়াই স্বয়ংক্রিয়ভাবে এজেন্টের কাছে পাঠান। স্ক্রিন লক হলে থেমে যায়।',
'voice.providers.saved': 'ভয়েস প্রদানকারী সংরক্ষিত।',
'voice.providers.failedToSave': 'ভয়েস প্রদানকারী সংরক্ষণ করতে ব্যর্থ',
'voice.providers.ellipsis': '…',
Expand Down Expand Up @@ -4809,6 +4812,13 @@ const messages: TranslationMap = {
'runQueue.collectHint': 'অতিরিক্ত প্রসঙ্গ হিসেবে যোগ করুন',
'runQueue.status': '{total}টি সারিবদ্ধ',
'runQueue.cleared': 'সারি পরিষ্কার করা হয়েছে',
'notch.ready': 'প্রস্তুত',
'notch.processing': 'প্রক্রিয়াকরণ চলছে…',
'notch.listening': 'শুনছি…',
'notch.thinking': 'ভাবছি…',
'notch.speaking': 'বলছি…',
'notch.transcribing': 'ট্রান্সক্রাইব করছি…',
'notch.executing': 'চালাচ্ছি…',
};

export default messages;
10 changes: 10 additions & 0 deletions app/src/lib/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1459,6 +1459,9 @@ const messages: TranslationMap = {
'voice.debug.silenceThreshold': 'Ruheschwelle (RMS)',
'voice.debug.silenceThresholdDesc':
'Aufnahmen mit Energie unterhalb dieses Wertes werden als Stille behandelt und übersprungen. Niedriger = empfindlicher.',
'voice.debug.alwaysOn': 'Dauerhaftes Zuhören',
'voice.debug.alwaysOnDesc':
'Hält das Mikrofon offen und sendet das Gesagte automatisch an den Agenten, ohne Tastenkürzel. Pausiert, wenn der Bildschirm gesperrt ist.',
'voice.providers.saved': 'Sprachanbieter gespeichert.',
'voice.providers.failedToSave': 'Sprachanbieter konnten nicht gespeichert werden.',
'voice.providers.ellipsis': '…',
Expand Down Expand Up @@ -4944,6 +4947,13 @@ const messages: TranslationMap = {
'runQueue.collectHint': 'Als zusätzlichen Kontext hinzufügen',
'runQueue.status': '{total} in der Warteschlange',
'runQueue.cleared': 'Warteschlange geleert',
'notch.ready': 'Bereit',
'notch.processing': 'Wird verarbeitet…',
'notch.listening': 'Höre zu…',
'notch.thinking': 'Denke nach…',
'notch.speaking': 'Spreche…',
'notch.transcribing': 'Transkribiere…',
'notch.executing': 'Führe aus…',
};

export default messages;
10 changes: 10 additions & 0 deletions app/src/lib/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1636,6 +1636,9 @@ const en: TranslationMap = {
'voice.debug.silenceThreshold': 'Silence Threshold (RMS)',
'voice.debug.silenceThresholdDesc':
'Recordings with energy below this are treated as silence and skipped. Lower = more sensitive.',
'voice.debug.alwaysOn': 'Always-on listening',
'voice.debug.alwaysOnDesc':
'Keep the microphone open and send what you say to the agent automatically, no hotkey. Pauses when the screen is locked.',
'voice.providers.saved': 'Voice providers saved.',
'voice.providers.failedToSave': 'Failed to save voice providers',
'voice.providers.ellipsis': '…',
Expand Down Expand Up @@ -5056,6 +5059,13 @@ const en: TranslationMap = {
'runQueue.collectHint': 'Add as extra context',
'runQueue.status': '{total} queued',
'runQueue.cleared': 'Queue cleared',
'notch.ready': 'Ready',
'notch.processing': 'Processing…',
'notch.listening': 'Listening…',
'notch.thinking': 'Thinking…',
'notch.speaking': 'Speaking…',
'notch.transcribing': 'Transcribing…',
'notch.executing': 'Executing…',
};

export default en;
Loading
Loading