diff --git a/install.sh b/install.sh index 6f3c25b..0fd2af4 100755 --- a/install.sh +++ b/install.sh @@ -890,14 +890,14 @@ EOF echo " │ • Good for basic dictation needs │" echo " └─────────────────────────────────────────────────────────────┘" echo "" - echo " ┌─────────────────────────────────────────────────────────────┐" - echo " │ 4. REMOTE API (ADVANCED) │" - echo " │ • Offload processing to a GPU server on your network │" - echo " │ • Ideal for laptops without GPU │" + echo " ┌───────────────────────────────────────────────────────────────┐" + echo " │ 4. REMOTE API (ADVANCED) │" + echo " │ • Offload processing to a GPU server on your network │" + echo " │ • Ideal for laptops without GPU │" echo " │ • Supports whisper.cpp server & OpenAI-compatible APIs │" - echo " │ • Minimal local resources needed │" - echo " │ • Requires a remote server to be running │" - echo " └─────────────────────────────────────────────────────────────┘" + echo " │ • Minimal local resources needed │" + echo " │ • Requires a remote server to be running │" + echo " └───────────────────────────────────────────────────────────────┘" echo "" # Show recommendation diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 4062e04..576deb1 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -906,6 +906,7 @@ class SettingsDialog(Gtk.Dialog): self._build_general_section() self._build_audio_section() self._build_engine_section() + self._build_remote_server_section() self._build_recognition_section() self._build_shortcuts_section() self._build_advanced_section() @@ -1204,7 +1205,9 @@ class SettingsDialog(Gtk.Dialog): self.config_manager.save_settings() # Auto apply only if remote engine is currently active - if self.use_remote_switch.get_active(): + engine_text = self.engine_combo.get_active_text() + engine = _engine_from_display(engine_text) if engine_text else "vosk" + if engine == "remote_api": self._auto_apply_settings() def _on_test_remote_connection(self, widget): @@ -1233,35 +1236,35 @@ class SettingsDialog(Gtk.Dialog): clean_url = url.rstrip("/") - # Try connecting to the server - response = requests.get(clean_url, headers=headers, timeout=5) - - # Determine server type - server_info = "" - + session = requests.Session() try: - if endpoint == "/v1/audio/transcriptions": - # Try OpenAI endpoint - openai_resp = requests.get( - f"{clean_url}/v1/models", headers=headers, timeout=5 - ) - if openai_resp.status_code == 200: - server_info = " (OpenAI compatible)" - elif endpoint == "/inference": - # Try whisper.cpp server endpoint - whispercpp_resp = requests.get( - f"{clean_url}/inference", headers=headers, timeout=5 - ) - if whispercpp_resp.status_code != 404: - server_info = " (whisper.cpp server)" - except Exception: - pass + response = session.get(clean_url, headers=headers, timeout=5) - GLib.idle_add( - self.remote_status_label.set_markup, - f"✓ Connected! " - f"(status={response.status_code}){server_info}", - ) + server_info = "" + + try: + if endpoint == "/v1/audio/transcriptions": + openai_resp = session.get( + f"{clean_url}/v1/models", headers=headers, timeout=5 + ) + if openai_resp.status_code == 200: + server_info = " (OpenAI compatible)" + elif endpoint == "/inference": + whispercpp_resp = session.get( + f"{clean_url}/inference", headers=headers, timeout=5 + ) + if whispercpp_resp.status_code != 404: + server_info = " (whisper.cpp server)" + except Exception: + pass + + GLib.idle_add( + self.remote_status_label.set_markup, + f"✓ Connected! " + f"(status={response.status_code}){server_info}", + ) + finally: + session.close() except Exception as e: error_msg = str(e)[:80] @@ -1270,7 +1273,6 @@ class SettingsDialog(Gtk.Dialog): f"✗ Connection failed: {error_msg}", ) - # Restore button state GLib.idle_add(self.remote_test_btn.set_sensitive, True) GLib.idle_add(self.remote_test_btn.set_label, "Test Connection") @@ -1644,7 +1646,7 @@ class SettingsDialog(Gtk.Dialog): # Scrollable container for the controls scrolled = Gtk.ScrolledWindow() scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - scrolled.set_min_content_height(350) + scrolled.set_min_content_height(500) controls_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) @@ -1781,6 +1783,11 @@ class SettingsDialog(Gtk.Dialog): controls_box.pack_start(info_box, False, False, 0) controls_box.pack_start(group, False, False, 0) + separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) + separator.set_margin_top(8) + separator.set_margin_bottom(8) + controls_box.pack_start(separator, False, False, 0) + scrolled.add(controls_box) self.advanced_revealer.add(scrolled) self.advanced_tab.pack_start(self.advanced_revealer, True, True, 0) @@ -1798,8 +1805,9 @@ class SettingsDialog(Gtk.Dialog): self.power_user_switch.connect("state-set", self._on_power_user_toggled) - """Build the Advanced tab (currently: Remote Server settings).""" - self.remote_api_group = PreferencesGroup( + def _build_remote_server_section(self): + """Build the Remote Server configuration section (shown when Remote API engine is selected).""" + self.remote_server_group = PreferencesGroup( title="Remote Server", description=( "Offload speech recognition to a server on your network. " @@ -1807,20 +1815,6 @@ class SettingsDialog(Gtk.Dialog): ), ) - # Toggle: enable remote engine (overrides Speech Engine tab selection) - self.use_remote_switch = Gtk.Switch() - self.use_remote_switch.set_active(False) - self.use_remote_switch.set_tooltip_text( - "When enabled, transcription is performed by the configured remote " - "server instead of the local engine selected on the Speech Engine tab." - ) - use_remote_row = PreferenceRow( - title="Use remote server", - subtitle="Override the local engine and send audio to a remote server", - widget=self.use_remote_switch, - ) - self.remote_api_group.add_row(use_remote_row) - # Server URL self.remote_api_url_entry = Gtk.Entry() self.remote_api_url_entry.set_placeholder_text("http://192.168.1.100:8080") @@ -1834,7 +1828,7 @@ class SettingsDialog(Gtk.Dialog): subtitle="Remote speech recognition server address", widget=self.remote_api_url_entry, ) - self.remote_api_group.add_row(remote_url_row) + self.remote_server_group.add_row(remote_url_row) # API Key self.remote_api_key_entry = Gtk.Entry() @@ -1847,7 +1841,7 @@ class SettingsDialog(Gtk.Dialog): subtitle="Authentication key (optional)", widget=self.remote_api_key_entry, ) - self.remote_api_group.add_row(remote_key_row) + self.remote_server_group.add_row(remote_key_row) # API Endpoint self.remote_api_endpoint_combo = Gtk.ComboBoxText() @@ -1865,7 +1859,7 @@ class SettingsDialog(Gtk.Dialog): subtitle="API format for the remote server", widget=self.remote_api_endpoint_combo, ) - self.remote_api_group.add_row(remote_endpoint_row) + self.remote_server_group.add_row(remote_endpoint_row) # Connection test self.remote_test_btn = Gtk.Button(label="Test Connection") @@ -1876,16 +1870,16 @@ class SettingsDialog(Gtk.Dialog): subtitle="Verify remote server is reachable", widget=self.remote_test_btn, ) - self.remote_api_group.add_row(remote_test_row) + self.remote_server_group.add_row(remote_test_row) - self.advanced_tab.pack_start(self.remote_api_group, False, False, 0) + self.content_box.pack_start(self.remote_server_group, False, False, 0) # Status label below the group self.remote_status_label = Gtk.Label(label="", use_markup=True, xalign=0) self.remote_status_label.set_margin_start(16) self.remote_status_label.set_margin_top(4) self.remote_status_label.get_style_context().add_class("status-info") - self.advanced_tab.pack_start(self.remote_status_label, False, False, 0) + self.content_box.pack_start(self.remote_status_label, False, False, 0) # Load saved values into the widgets saved_url = self.config_manager.get("speech_recognition", "remote_api_url", "") @@ -1899,12 +1893,13 @@ class SettingsDialog(Gtk.Dialog): self.remote_api_key_entry.set_text(saved_key) self.remote_api_endpoint_combo.set_active_id(saved_endpoint) - # Connect signals (toggle and field changes both trigger re-apply) - self.use_remote_switch.connect("notify::active", self._on_use_remote_toggled) self.remote_api_url_entry.connect("changed", self._on_remote_api_settings_changed) self.remote_api_key_entry.connect("changed", self._on_remote_api_settings_changed) self.remote_api_endpoint_combo.connect("changed", self._on_remote_api_settings_changed) + self.remote_server_group.hide() + self.remote_status_label.hide() + def _on_power_user_toggled(self, widget, state): """Handle the power-user opt-in toggle.""" if self._initializing or self._applying_settings: @@ -1999,13 +1994,6 @@ class SettingsDialog(Gtk.Dialog): self._auto_apply_settings() - def _on_use_remote_toggled(self, switch, _gparam): - """Toggle remote-engine override: forces engine to remote_api when on.""" - if self._initializing or self._applying_settings: - return - self._update_engine_specific_ui() - self._auto_apply_settings() - def _load_and_apply_settings(self): """Load current settings and populate the UI.""" settings = self._get_current_settings() @@ -2032,54 +2020,33 @@ class SettingsDialog(Gtk.Dialog): self.copy_to_clipboard_switch.set_active(copy_to_clipboard) self.sound_effects_switch.set_active(self.config_manager.is_sound_effects_enabled()) - # Populate engine combo with only available LOCAL engines. - # Remote API is not a peer engine in the combo — it lives on the - # Advanced tab as a toggle that overrides the local engine. available_engines = get_available_engines() available_count = 0 for engine in ENGINE_MODELS.keys(): - if engine == "remote_api": - continue if available_engines.get(engine, False): display_name = _engine_display_name(engine) self.engine_combo.append(display_name, display_name) available_count += 1 if available_count == 0: - logger.error("No local speech recognition engines available!") + logger.error("No speech recognition engines available!") # Still add them so the UI works, but log the error for engine in ENGINE_MODELS.keys(): - if engine == "remote_api": - continue display_name = _engine_display_name(engine) self.engine_combo.append(display_name, display_name) else: - logger.info(f"Populated {available_count} available local engines: {available_engines}") + logger.info(f"Populated {available_count} available engines: {available_engines}") - # If config says remote_api, turn the Advanced toggle on and pick a - # local fallback for the combo (so toggling off has somewhere to land). - remote_active = self.current_engine == "remote_api" - if remote_active: - self.use_remote_switch.set_active(True) - local_fallback = "whisper_cpp" - if not available_engines.get(local_fallback, False): - for engine in ENGINE_MODELS.keys(): - if engine != "remote_api" and available_engines.get(engine, False): - local_fallback = engine - break - self.current_engine = local_fallback - else: - self.use_remote_switch.set_active(False) - if not available_engines.get(self.current_engine, False): - logger.warning( - f"Current engine '{self.current_engine}' is not available, " - "selecting first available" - ) - for engine in ENGINE_MODELS.keys(): - if engine != "remote_api" and available_engines.get(engine, False): - self.current_engine = engine - break + if not available_engines.get(self.current_engine, False): + logger.warning( + f"Current engine '{self.current_engine}' is not available, " + "selecting first available" + ) + for engine in ENGINE_MODELS.keys(): + if available_engines.get(engine, False): + self.current_engine = engine + break engine_text = _engine_display_name(self.current_engine) logger.info(f"Setting active engine to: {engine_text}") @@ -2383,25 +2350,22 @@ class SettingsDialog(Gtk.Dialog): self._processing_language_change = False def _update_engine_specific_ui(self): - """Show/hide UI elements driven by the active engine. + """Show/hide UI elements driven by the active engine.""" + engine_text = self.engine_combo.get_active_text() + engine = _engine_from_display(engine_text) if engine_text else "vosk" + is_remote = engine == "remote_api" - The engine combo is local-only (vosk/whisper/whisper_cpp). The remote - engine is enabled via the Advanced tab's "Use remote server" switch, - which overrides the local selection. - """ - is_remote = ( - getattr(self, "use_remote_switch", None) is not None - and self.use_remote_switch.get_active() - ) - - # When remote is active, the local model/legend UI is irrelevant if is_remote: self.model_row.hide() self.model_info_card.hide() self.legend_box.hide() + self.remote_server_group.show_all() + self.remote_status_label.show() else: self.model_row.show_all() self.legend_box.show_all() + self.remote_server_group.hide() + self.remote_status_label.hide() self._update_model_info() self._update_advanced_tab_sensitivity() @@ -2439,14 +2403,6 @@ class SettingsDialog(Gtk.Dialog): self.model_info_card.hide() return - # Remote engine override (Advanced tab) hides the local model info card. - if ( - getattr(self, "use_remote_switch", None) is not None - and self.use_remote_switch.get_active() - ): - self.model_info_card.hide() - return - engine = _engine_from_display(engine_text) model_id = self.model_combo.get_active_id() @@ -2628,11 +2584,7 @@ class SettingsDialog(Gtk.Dialog): model_id = self.model_combo.get_active_id() language_id = self.language_combo.get_active_id() - # Advanced "Use remote server" toggle overrides the local engine combo. - if self.use_remote_switch.get_active(): - engine = "remote_api" - else: - engine = _engine_from_display(engine_text) if engine_text else "vosk" + engine = _engine_from_display(engine_text) if engine_text else "vosk" model_size = model_id.lower() if model_id else "small" language = language_id if language_id else "auto" diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 259ca55..17e7e1d 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -496,6 +496,43 @@ class TestSettingsDialogInstantApply(unittest.TestCase): source_code.index("controls_box.pack_start(group"), ) + def test_remote_api_section_in_speech_engine_tab(self): + import os + + source_path = os.path.join( + os.path.dirname(__file__), + "..", + "src", + "vocalinux", + "ui", + "settings_dialog.py", + ) + with open(source_path, "r") as f: + source_code = f.read() + + self.assertIn("self.content_box.pack_start(self.remote_server_group", source_code) + self.assertIn("self.content_box.pack_start(self.remote_status_label", source_code) + self.assertNotIn("advanced_tab.pack_start(self.remote_server_group", source_code) + self.assertNotIn("advanced_tab.pack_start(self.remote_status_label", source_code) + self.assertNotIn("self.use_remote_switch", source_code) + + def test_connection_test_uses_session(self): + import os + + source_path = os.path.join( + os.path.dirname(__file__), + "..", + "src", + "vocalinux", + "ui", + "settings_dialog.py", + ) + with open(source_path, "r") as f: + source_code = f.read() + + self.assertIn("session = requests.Session()", source_code) + self.assertIn("session.close()", source_code) + class TestSettingsDialogHelperFunctions(unittest.TestCase): """Test cases for settings dialog helper functions."""