feat(voice): always-on Settings toggle + debug panel + i18n (5/8 of #3307) (#3344)

This commit is contained in:
Mega Mind
2026-06-04 21:24:08 +05:30
committed by GitHub
parent ea7ada79a4
commit e40fec9331
19 changed files with 366 additions and 0 deletions
@@ -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);
@@ -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')}
@@ -19,6 +19,7 @@ import {
} from '../../../services/api/voiceSettingsApi';
import {
openhumanGetVoiceServerSettings,
openhumanUpdateVoiceServerSettings,
openhumanVoiceSetProviders,
openhumanVoiceStatus,
type VoiceProvidersSnapshot,
@@ -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);
}
}}
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
@@ -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 })
)
);
});
});
@@ -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(<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
View File
@@ -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;
+10
View File
@@ -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;
+10
View File
@@ -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;
+10
View File
@@ -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;
+10
View File
@@ -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;
+10
View File
@@ -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 à lagent, 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;
+10
View File
@@ -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;
+10
View File
@@ -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;
+10
View File
@@ -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 allagente, 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;
+10
View File
@@ -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;
+10
View File
@@ -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;
+10
View File
@@ -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;
+10
View File
@@ -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;
+10
View File
@@ -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;
+3
View File
@@ -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<VoiceStatus> {
@@ -106,6 +108,7 @@ export async function openhumanUpdateVoiceServerSettings(update: {
min_duration_secs?: number;
silence_threshold?: number;
custom_dictionary?: string[];
always_on_enabled?: boolean;
}): Promise<CommandResponse<ConfigSnapshot>> {
return await callCoreRpc<CommandResponse<ConfigSnapshot>>({
method: 'openhuman.config_update_voice_server_settings',