← Whispering

fix: better TTS error handling when backends not installed

4401de1d90415ba6f3faab537ed9f61e169ab487 · Claude · Feb 10, 2026 18:05

- get_available_backends() now does deep import checks (verifies
  ChatterboxTTS/Qwen3TTSModel classes, not just top-level package)
  so partially-installed packages don't false-positive
- TTS section always shows in UI even when no backends installed,
  with clear "No TTS engine installed" message and install command
- Backend dropdown labels show "(not installed)" for missing engines
- Selecting an unavailable backend shows install command in status
  instead of a cryptic error
- Enabling TTS with unavailable backend is blocked with clear message
- Error/warning messages display in orange instead of blue
- TTS controller only created when at least one backend is available

https://claude.ai/code/session_01965m1v3f1cK6gDV1JHioA8

+94 −58 · 3 files changed

  • src/tts_provider.py +11 −7
  • src/whispering_ui/components/sidebar.py +46 −21
  • src/whispering_ui/main.py +37 −30
diff --git a/src/tts_provider.py b/src/tts_provider.py
index 70cc886..8a73c64 100644
--- a/src/tts_provider.py
+++ b/src/tts_provider.py
@@ -22,21 +22,25 @@ warnings.filterwarnings('ignore', category=UserWarning)
 # ---------------------------------------------------------------------------
 
 def get_available_backends() -> dict[str, bool]:
-    """Check which TTS backends are installed and importable."""
+    """Check which TTS backends are installed and importable.
+
+    Does a deep import check (tries the actual class, not just the
+    top-level package) so partially-installed packages don't false-positive.
+    """
     backends = {}
 
-    # Check Chatterbox
+    # Check Chatterbox - verify the actual TTS class is importable
     try:
-        import chatterbox  # noqa: F401
+        from chatterbox.tts import ChatterboxTTS  # noqa: F401
         backends["chatterbox"] = True
-    except ImportError:
+    except (ImportError, ModuleNotFoundError, Exception):
         backends["chatterbox"] = False
 
-    # Check Qwen3-TTS
+    # Check Qwen3-TTS - verify the model class is importable
     try:
-        import qwen_tts  # noqa: F401
+        from qwen_tts import Qwen3TTSModel  # noqa: F401
         backends["qwen3"] = True
-    except ImportError:
+    except (ImportError, ModuleNotFoundError, Exception):
         backends["qwen3"] = False
 
     return backends
diff --git a/src/whispering_ui/components/sidebar.py b/src/whispering_ui/components/sidebar.py
index cdd9852..b4289c6 100644
--- a/src/whispering_ui/components/sidebar.py
+++ b/src/whispering_ui/components/sidebar.py
@@ -554,21 +554,20 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
                 return control
 
             if state.tts_available:
+                has_any_backend = any(state.tts_backends_available.values())
+
                 with tts_section:
                     # Backend selection
                     with ui.row().classes('items-center w-full gap-1'):
                         ui.label('Engine:').classes('text-xs w-10')
-                        # Build backend options from what's available
-                        backend_options = []
-                        if state.tts_backends_available.get("chatterbox"):
-                            backend_options.append("chatterbox")
-                        if state.tts_backends_available.get("qwen3"):
-                            backend_options.append("qwen3")
-                        # Always show both options so user knows what's possible
-                        if "chatterbox" not in backend_options:
-                            backend_options.append("chatterbox")
-                        if "qwen3" not in backend_options:
-                            backend_options.append("qwen3")
+                        # Build backend options with availability labels
+                        backend_options = {}
+                        for bname in ["chatterbox", "qwen3"]:
+                            installed = state.tts_backends_available.get(bname, False)
+                            if installed:
+                                backend_options[bname] = bname
+                            else:
+                                backend_options[bname] = f"{bname} (not installed)"
 
                         tts_backend_select = register_tts(ui.select(
                             options=backend_options,
@@ -576,18 +575,31 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
                         ).classes('flex-grow').props('dense'))
 
                         def on_backend_change(e):
-                            state.tts_backend = e.value
-                            if bridge.tts_controller:
-                                bridge.tts_controller.switch_backend(
-                                    backend=e.value,
-                                    qwen3_model_size=state.tts_qwen3_model_size,
-                                    qwen3_speaker=state.tts_qwen3_speaker,
-                                )
+                            selected = e.value
+                            installed = state.tts_backends_available.get(selected, False)
+                            if not installed:
+                                state.tts_status_message = f"{selected} not installed. Run: ./scripts/install.sh --tts={selected}"
+                                state.tts_enabled = False
+                                tts_cb.value = False
+                            else:
+                                state.tts_backend = selected
+                                if bridge.tts_controller:
+                                    bridge.tts_controller.switch_backend(
+                                        backend=selected,
+                                        qwen3_model_size=state.tts_qwen3_model_size,
+                                        qwen3_speaker=state.tts_qwen3_speaker,
+                                    )
+                                state.tts_status_message = ""
                             # Show/hide Qwen3 options
-                            qwen3_row.set_visibility(e.value == "qwen3")
+                            qwen3_row.set_visibility(selected == "qwen3")
 
                         tts_backend_select.on_value_change(on_backend_change)
 
+                    # Install hint when no backends available
+                    if not has_any_backend:
+                        ui.label('No TTS engine installed').classes('text-xs text-orange-400')
+                        ui.label('Run: ./scripts/install.sh --tts').classes('text-xs text-gray-500')
+
                     # Qwen3-TTS specific options (speaker + model size)
                     qwen3_row = ui.row().classes('items-center w-full gap-1')
                     qwen3_row.set_visibility(state.tts_backend == "qwen3")
@@ -669,8 +681,14 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
                     tts_status_label = ui.label('').classes('text-xs text-blue-400')
 
                     def update_tts_status():
-                        if state.tts_status_message:
-                            tts_status_label.text = state.tts_status_message[:50]
+                        msg = state.tts_status_message
+                        if msg:
+                            # Color errors orange/red
+                            if 'not installed' in msg or 'error' in msg.lower() or 'failed' in msg.lower():
+                                tts_status_label.classes(replace='text-xs text-orange-400')
+                            else:
+                                tts_status_label.classes(replace='text-xs text-blue-400')
+                            tts_status_label.text = msg[:60]
                         else:
                             tts_status_label.text = ''
                         # Sync playback state from controller
@@ -680,6 +698,13 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
                     ui.timer(0.2, update_tts_status)
 
             def on_tts_toggle(e):
+                # Check if selected backend is actually installed
+                backend_installed = state.tts_backends_available.get(state.tts_backend, False)
+                if e.value and not backend_installed:
+                    state.tts_status_message = f"{state.tts_backend} not installed. Run: ./scripts/install.sh --tts"
+                    e.sender.value = False
+                    state.tts_enabled = False
+                    return
                 state.tts_enabled = e.value
                 active = e.value and state.tts_available
                 _set_section_visual_state(tts_section, active)
diff --git a/src/whispering_ui/main.py b/src/whispering_ui/main.py
index 0ec242f..e157fbf 100644
--- a/src/whispering_ui/main.py
+++ b/src/whispering_ui/main.py
@@ -86,40 +86,48 @@ def main():
         state.ai_available = False
 
     # Check TTS availability
+    # Always show TTS section so user can see install instructions.
+    # tts_available=True means the TTS UI section is visible.
+    # tts_backends_available tracks which engines are actually installed.
     try:
         from tts_provider import get_available_backends
         from tts_controller import TTSController
 
         backends = get_available_backends()
         state.tts_backends_available = backends
-        state.tts_available = any(backends.values())
-
-        if state.tts_available:
-            # Load TTS settings
-            state.tts_enabled = settings.get("tts_enabled", False)
-            state.tts_backend = settings.get("tts_backend", "chatterbox")
-            state.tts_source = settings.get("tts_source", "whisper")
-            state.tts_auto_play = settings.get("tts_auto_play", True)
-            state.tts_save_file = settings.get("tts_save_file", False)
-            state.tts_format = settings.get("tts_format", "wav")
-            state.tts_qwen3_speaker = settings.get("tts_qwen3_speaker", "Ryan")
-            state.tts_qwen3_model_size = settings.get("tts_qwen3_model_size", "1.7B")
-
-            # If saved backend is not installed, fall back to first available
-            if not backends.get(state.tts_backend):
-                for name, avail in backends.items():
-                    if avail:
-                        state.tts_backend = name
-                        break
-
-            # Print backend status
+        # Show TTS section whenever our modules load (always)
+        state.tts_available = True
+
+        # Load TTS settings
+        state.tts_enabled = settings.get("tts_enabled", False)
+        state.tts_backend = settings.get("tts_backend", "chatterbox")
+        state.tts_source = settings.get("tts_source", "whisper")
+        state.tts_auto_play = settings.get("tts_auto_play", True)
+        state.tts_save_file = settings.get("tts_save_file", False)
+        state.tts_format = settings.get("tts_format", "wav")
+        state.tts_qwen3_speaker = settings.get("tts_qwen3_speaker", "Ryan")
+        state.tts_qwen3_model_size = settings.get("tts_qwen3_model_size", "1.7B")
+
+        # If saved backend is not installed, fall back to first available
+        if not backends.get(state.tts_backend):
             for name, avail in backends.items():
-                status = "available" if avail else "not installed"
-                print(f"  TTS backend {name}: {status}")
-        else:
-            print("No TTS backends installed. TTS features disabled.")
-            print("  Install Chatterbox: pip install chatterbox-tts --no-deps")
-            print("  Install Qwen3-TTS: pip install qwen-tts")
+                if avail:
+                    state.tts_backend = name
+                    break
+
+        # Print backend status
+        has_any = False
+        for name, avail in backends.items():
+            status = "available" if avail else "not installed"
+            print(f"  TTS backend {name}: {status}")
+            if avail:
+                has_any = True
+        if not has_any:
+            state.tts_enabled = False  # Don't auto-enable if nothing is installed
+            print("  No TTS backends installed.")
+            print("  Install with: ./scripts/install.sh --tts")
+            print("  Or manually: pip install chatterbox-tts --no-deps")
+            print("              pip install qwen-tts")
     except Exception as e:
         print(f"TTS features not available: {e}")
         state.tts_available = False
@@ -128,8 +136,8 @@ def main():
     # Create processing bridge
     bridge = ProcessingBridge(state)
 
-    # Initialize TTS controller in bridge if available
-    if state.tts_available:
+    # Initialize TTS controller in bridge if any backend is available
+    if state.tts_available and any(state.tts_backends_available.values()):
         try:
             from tts_controller import TTSController
             bridge.tts_controller = TTSController(
@@ -144,7 +152,6 @@ def main():
             bridge.tts_controller.on_error = lambda msg: setattr(state, 'tts_status_message', msg)
         except Exception as e:
             print(f"Failed to initialize TTS controller: {e}")
-            state.tts_available = False
 
     # === RECOVERY DIALOG ===
     # Check for crashed sessions and offer recovery