+ {/* ─── Always-on listening (Phase 2) ──────────────────────────── */}
+ {settings && (
+
+ )}
+
{/* ─── Section 1: Voice Provider Chips ─────────────────────────── */}
({ 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();
+
+ 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 })
+ )
+ );
+ });
+});
diff --git a/app/src/components/settings/panels/__tests__/VoicePanel.test.tsx b/app/src/components/settings/panels/__tests__/VoicePanel.test.tsx
index 5f0d3b337..4a54dc247 100644
--- a/app/src/components/settings/panels/__tests__/VoicePanel.test.tsx
+++ b/app/src/components/settings/panels/__tests__/VoicePanel.test.tsx
@@ -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(),
}));
@@ -111,6 +114,7 @@ describe('VoicePanel', () => {
min_duration_secs: 0.3,
silence_threshold: 0.002,
custom_dictionary: [],
+ always_on_enabled: false,
},
voiceStatus: {
stt_available: true,
@@ -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;
@@ -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(, { 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(, { 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);
+ });
});
diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts
index 5913bed48..dc527a2b8 100644
--- a/app/src/lib/i18n/ar.ts
+++ b/app/src/lib/i18n/ar.ts
@@ -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': '…',
@@ -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;
diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts
index 0adea28bc..b3db3295a 100644
--- a/app/src/lib/i18n/bn.ts
+++ b/app/src/lib/i18n/bn.ts
@@ -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': '…',
@@ -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;
diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts
index 6dfbf7ffe..dabe2c34f 100644
--- a/app/src/lib/i18n/de.ts
+++ b/app/src/lib/i18n/de.ts
@@ -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': '…',
@@ -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;
diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts
index 4ec3d60a0..c0334d4b3 100644
--- a/app/src/lib/i18n/en.ts
+++ b/app/src/lib/i18n/en.ts
@@ -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': '…',
@@ -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;
diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts
index eb84e71e2..6748e3bec 100644
--- a/app/src/lib/i18n/es.ts
+++ b/app/src/lib/i18n/es.ts
@@ -1453,6 +1453,9 @@ const messages: TranslationMap = {
'voice.debug.silenceThreshold': 'Umbral de silencio (RMS)',
'voice.debug.silenceThresholdDesc':
'Las grabaciones con energía por debajo de este valor se tratan como silencio y se omiten. Menor = más sensible.',
+ 'voice.debug.alwaysOn': 'Escucha continua',
+ 'voice.debug.alwaysOnDesc':
+ 'Mantén el micrófono abierto y envía lo que dices al agente automáticamente, sin atajo. Se pausa cuando la pantalla está bloqueada.',
'voice.providers.saved': 'Proveedores de voz guardados.',
'voice.providers.failedToSave': 'No se pudieron guardar los proveedores de voz',
'voice.providers.ellipsis': '…',
@@ -4912,6 +4915,13 @@ const messages: TranslationMap = {
'runQueue.collectHint': 'Añadir como contexto adicional',
'runQueue.status': '{total} en cola',
'runQueue.cleared': 'Cola vaciada',
+ 'notch.ready': 'Listo',
+ 'notch.processing': 'Procesando…',
+ 'notch.listening': 'Escuchando…',
+ 'notch.thinking': 'Pensando…',
+ 'notch.speaking': 'Hablando…',
+ 'notch.transcribing': 'Transcribiendo…',
+ 'notch.executing': 'Ejecutando…',
};
export default messages;
diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts
index 08751a352..08dec245f 100644
--- a/app/src/lib/i18n/fr.ts
+++ b/app/src/lib/i18n/fr.ts
@@ -1457,6 +1457,9 @@ const messages: TranslationMap = {
'voice.debug.silenceThreshold': 'Seuil de silence (RMS)',
'voice.debug.silenceThresholdDesc':
"Les enregistrements dont l'énergie est inférieure à ce seuil sont traités comme du silence et ignorés. Plus bas = plus sensible.",
+ 'voice.debug.alwaysOn': 'Écoute permanente',
+ 'voice.debug.alwaysOnDesc':
+ 'Garde le microphone ouvert et envoie automatiquement ce que vous dites à l’agent, sans raccourci. Se met en pause lorsque l’écran est verrouillé.',
'voice.providers.saved': 'Fournisseurs de voix enregistrés.',
'voice.providers.failedToSave': 'Échec de la sauvegarde des fournisseurs vocaux',
'voice.providers.ellipsis': '…',
@@ -4927,6 +4930,13 @@ const messages: TranslationMap = {
'runQueue.collectHint': 'Ajouter comme contexte supplémentaire',
'runQueue.status': '{total} en attente',
'runQueue.cleared': "File d'attente vidée",
+ 'notch.ready': 'Prêt',
+ 'notch.processing': 'Traitement…',
+ 'notch.listening': "J'écoute…",
+ 'notch.thinking': 'Je réfléchis…',
+ 'notch.speaking': 'Je parle…',
+ 'notch.transcribing': 'Transcription…',
+ 'notch.executing': "J'exécute…",
};
export default messages;
diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts
index 8eef73e15..92c9e54cb 100644
--- a/app/src/lib/i18n/hi.ts
+++ b/app/src/lib/i18n/hi.ts
@@ -1417,6 +1417,9 @@ const messages: TranslationMap = {
'voice.debug.silenceThreshold': 'साइलेंस थ्रेशोल्ड (आरएमएस)',
'voice.debug.silenceThresholdDesc':
'इससे कम ऊर्जा वाली रिकॉर्डिंग को साइलेंस माना जाता है और छोड़ दिया जाता है। कम = अधिक संवेदनशील।',
+ 'voice.debug.alwaysOn': 'हमेशा-चालू सुनना',
+ 'voice.debug.alwaysOnDesc':
+ 'माइक्रोफ़ोन खुला रखें और आप जो कहते हैं वह बिना हॉटकी के स्वचालित रूप से एजेंट को भेजें। स्क्रीन लॉक होने पर रुक जाता है।',
'voice.providers.saved': 'ध्वनि प्रदाता सहेजे गए.',
'voice.providers.failedToSave': 'ध्वनि प्रदाताओं को सहेजने में विफल',
'voice.providers.ellipsis': '…',
@@ -4816,6 +4819,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;
diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts
index 69a71238e..bbb8b2009 100644
--- a/app/src/lib/i18n/id.ts
+++ b/app/src/lib/i18n/id.ts
@@ -1424,6 +1424,9 @@ const messages: TranslationMap = {
'voice.debug.silenceThreshold': 'Ambang Batas Senyap (RMS)',
'voice.debug.silenceThresholdDesc':
'Rekaman dengan energi di bawah nilai ini dianggap sebagai keheningan dan dilewati. Lebih rendah = lebih sensitif.',
+ 'voice.debug.alwaysOn': 'Mendengarkan terus-menerus',
+ 'voice.debug.alwaysOnDesc':
+ 'Biarkan mikrofon tetap aktif dan kirim ucapan Anda ke agen secara otomatis, tanpa pintasan. Berhenti sementara saat layar terkunci.',
'voice.providers.saved': 'Penyedia suara disimpan.',
'voice.providers.failedToSave': 'Gagal menyimpan penyedia suara',
'voice.providers.ellipsis': '…',
@@ -4828,6 +4831,13 @@ const messages: TranslationMap = {
'runQueue.collectHint': 'Tambahkan sebagai konteks tambahan',
'runQueue.status': '{total} dalam antrean',
'runQueue.cleared': 'Antrean dikosongkan',
+ 'notch.ready': 'Siap',
+ 'notch.processing': 'Memproses…',
+ 'notch.listening': 'Mendengar…',
+ 'notch.thinking': 'Berpikir…',
+ 'notch.speaking': 'Berbicara…',
+ 'notch.transcribing': 'Mentranskrip…',
+ 'notch.executing': 'Mengeksekusi…',
};
export default messages;
diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts
index 04cb740db..7ae228265 100644
--- a/app/src/lib/i18n/it.ts
+++ b/app/src/lib/i18n/it.ts
@@ -1446,6 +1446,9 @@ const messages: TranslationMap = {
'voice.debug.silenceThreshold': 'Soglia di silenzio (RMS)',
'voice.debug.silenceThresholdDesc':
'Le registrazioni con energia inferiore a questa soglia vengono trattate come silenzio e saltate. Più basso = più sensibile.',
+ 'voice.debug.alwaysOn': 'Ascolto sempre attivo',
+ 'voice.debug.alwaysOnDesc':
+ 'Mantieni il microfono aperto e invia automaticamente ciò che dici all’agente, senza scorciatoia. Si mette in pausa quando lo schermo è bloccato.',
'voice.providers.saved': 'Fornitori di servizi vocali salvati.',
'voice.providers.failedToSave': 'Impossibile salvare i provider vocali',
'voice.providers.ellipsis': '…',
@@ -4902,6 +4905,13 @@ const messages: TranslationMap = {
'runQueue.collectHint': 'Aggiungi come contesto extra',
'runQueue.status': '{total} in coda',
'runQueue.cleared': 'Coda svuotata',
+ 'notch.ready': 'Pronto',
+ 'notch.processing': 'Elaborazione…',
+ 'notch.listening': 'Ascolto…',
+ 'notch.thinking': 'Penso…',
+ 'notch.speaking': 'Parlo…',
+ 'notch.transcribing': 'Trascrizione…',
+ 'notch.executing': 'Eseguendo…',
};
export default messages;
diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts
index 8469c7ef3..b7171c436 100644
--- a/app/src/lib/i18n/ko.ts
+++ b/app/src/lib/i18n/ko.ts
@@ -1403,6 +1403,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': '…',
@@ -4764,6 +4767,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;
diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts
index 14e63c819..763f2a2be 100644
--- a/app/src/lib/i18n/pl.ts
+++ b/app/src/lib/i18n/pl.ts
@@ -1438,6 +1438,9 @@ const messages: TranslationMap = {
'voice.debug.silenceThreshold': 'Próg ciszy (RMS)',
'voice.debug.silenceThresholdDesc':
'Nagrania z energią poniżej tego progu są traktowane jako cisza i pomijane. Niżej = bardziej czułe.',
+ 'voice.debug.alwaysOn': 'Ciągłe nasłuchiwanie',
+ 'voice.debug.alwaysOnDesc':
+ 'Utrzymuje mikrofon włączony i automatycznie wysyła to, co mówisz, do agenta, bez skrótu. Wstrzymuje się, gdy ekran jest zablokowany.',
'voice.providers.saved': 'Zapisano dostawców głosu.',
'voice.providers.failedToSave': 'Nie udało się zapisać dostawców głosu',
'voice.providers.ellipsis': '…',
@@ -4893,6 +4896,13 @@ const messages: TranslationMap = {
'runQueue.collectHint': 'Dodaj jako dodatkowy kontekst',
'runQueue.status': '{total} w kolejce',
'runQueue.cleared': 'Kolejka wyczyszczona',
+ 'notch.ready': 'Gotowe',
+ 'notch.processing': 'Przetwarzanie…',
+ 'notch.listening': 'Słucham…',
+ 'notch.thinking': 'Myślę…',
+ 'notch.speaking': 'Mówię…',
+ 'notch.transcribing': 'Transkrybuję…',
+ 'notch.executing': 'Wykonuję…',
};
export default messages;
diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts
index 0fa21e243..8b9699e3a 100644
--- a/app/src/lib/i18n/pt.ts
+++ b/app/src/lib/i18n/pt.ts
@@ -1453,6 +1453,9 @@ const messages: TranslationMap = {
'voice.debug.silenceThreshold': 'Limite de Silêncio (RMS)',
'voice.debug.silenceThresholdDesc':
'Gravações com energia abaixo deste valor são tratadas como silêncio e ignoradas. Menor = mais sensível.',
+ 'voice.debug.alwaysOn': 'Escuta contínua',
+ 'voice.debug.alwaysOnDesc':
+ 'Mantém o microfone aberto e envia o que você diz ao agente automaticamente, sem atalho. Pausa quando a tela está bloqueada.',
'voice.providers.saved': 'Provedores de voz salvos.',
'voice.providers.failedToSave': 'Falha ao salvar provedores de voz',
'voice.providers.ellipsis': '…',
@@ -4897,6 +4900,13 @@ const messages: TranslationMap = {
'runQueue.collectHint': 'Adicionar como contexto extra',
'runQueue.status': '{total} na fila',
'runQueue.cleared': 'Fila limpa',
+ 'notch.ready': 'Pronto',
+ 'notch.processing': 'Processando…',
+ 'notch.listening': 'Ouvindo…',
+ 'notch.thinking': 'Pensando…',
+ 'notch.speaking': 'Falando…',
+ 'notch.transcribing': 'Transcrevendo…',
+ 'notch.executing': 'Executando…',
};
export default messages;
diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts
index 88c240cba..910380db5 100644
--- a/app/src/lib/i18n/ru.ts
+++ b/app/src/lib/i18n/ru.ts
@@ -1431,6 +1431,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': '…',
@@ -4859,6 +4862,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;
diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts
index a07fd592f..ac899a461 100644
--- a/app/src/lib/i18n/zh-CN.ts
+++ b/app/src/lib/i18n/zh-CN.ts
@@ -1340,6 +1340,9 @@ const messages: TranslationMap = {
'voice.debug.minimumRecordingSeconds': '最短录音秒数',
'voice.debug.silenceThreshold': '静音阈值 (RMS)',
'voice.debug.silenceThresholdDesc': '能量低于此值的录音将被视为静音并跳过。值越低,灵敏度越高。',
+ 'voice.debug.alwaysOn': '常驻聆听',
+ 'voice.debug.alwaysOnDesc':
+ '保持麦克风开启,无需快捷键即可自动将你说的话发送给智能体。屏幕锁定时暂停。',
'voice.providers.saved': '语音提供商已保存。',
'voice.providers.failedToSave': '无法保存语音提供商',
'voice.providers.ellipsis': '…',
@@ -4578,6 +4581,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;
diff --git a/app/src/utils/tauriCommands/voice.ts b/app/src/utils/tauriCommands/voice.ts
index 295c90fe6..d244cba93 100644
--- a/app/src/utils/tauriCommands/voice.ts
+++ b/app/src/utils/tauriCommands/voice.ts
@@ -58,6 +58,8 @@ export interface VoiceServerSettings {
silence_threshold: number;
/** Custom vocabulary words to bias whisper toward (names, technical terms). */
custom_dictionary: string[];
+ /** Phase 2: continuous always-on listening (no hotkey). Opt-in. */
+ always_on_enabled: boolean;
}
export async function openhumanVoiceStatus(): Promise {
@@ -106,6 +108,7 @@ export async function openhumanUpdateVoiceServerSettings(update: {
min_duration_secs?: number;
silence_threshold?: number;
custom_dictionary?: string[];
+ always_on_enabled?: boolean;
}): Promise> {
return await callCoreRpc>({
method: 'openhuman.config_update_voice_server_settings',