← Whispering

feat: Add all missing features from tkinter GUI to NiceGUI

658a9bd0c347ebdf25be1c60da235b3c554e924a · Claude · Dec 1, 2025 20:48

Complete feature parity with original tkinter GUI:

**State Management:**
- Add TTS voice display name and status message to state

**Sidebar - AI Processing:**
- Add "Translate Only (1:1)" checkbox
- Add AI Model selection dropdown
- Add Manual Mode checkbox with "Process NOW" button
- Add Trigger mode selection (Time/Words)
- Add Interval dropdown (5s-2m) for Time trigger
- Add Words spinbox (50-500) for Words trigger
- Implement dynamic enable/disable of controls based on mode

**Sidebar - TTS:**
- Add Source selection (W/A/T mutually exclusive checkboxes)
- Add Voice selection with display label
- Add Browse and Clear buttons for voice reference
- Add Output format selection (wav/ogg)
- Add TTS status message display

**Output Panels:**
- Add Copy button for each text area (Whisper/AI/Translation)
- Add Cut button for each text area (copy + clear)
- Implement clipboard operations using JavaScript API

**Performance:**
- Fix audio level indicator responsiveness (50ms update, no smoothing)
- Change from 100ms to 50ms UI update timer for better audio visualization

**Settings Persistence:**
- Save/load TTS format preference

All features from the original tkinter GUI are now present in the NiceGUI version.

+270 −17 · 4 files changed

  • src/whispering_ui/components/output.py +75 −6
  • src/whispering_ui/components/sidebar.py +191 −11
  • src/whispering_ui/main.py +2 −0
  • src/whispering_ui/state.py +2 −0
diff --git a/src/whispering_ui/components/output.py b/src/whispering_ui/components/output.py
index 80025f8..fa726b4 100644
--- a/src/whispering_ui/components/output.py
+++ b/src/whispering_ui/components/output.py
@@ -1,7 +1,7 @@
 #!/usr/bin/env python3
 """
-Simplified Output Component
-Text display panels for Whisper, AI, and Translation output
+Output Component
+Text display panels for Whisper, AI, and Translation output with Copy/Cut buttons
 """
 
 from nicegui import ui
@@ -10,7 +10,7 @@ from whispering_ui.state import AppState
 
 def create_output_panels(state: AppState):
     """
-    Create output text panels with simplified updates.
+    Create output text panels with Copy/Cut buttons.
 
     Args:
         state: Application state
@@ -26,7 +26,12 @@ def create_output_panels(state: AppState):
         with ui.column().classes('flex-grow'):
             with ui.row().classes('items-center justify-between w-full mb-1'):
                 ui.label('Whisper Output').classes('font-bold')
-                whisper_count = ui.label('0 chars, 0 words').classes('text-xs text-gray-500')
+
+                # Count and buttons
+                with ui.row().classes('items-center gap-2'):
+                    whisper_count = ui.label('0 chars, 0 words').classes('text-xs text-gray-500')
+                    ui.button('Copy', on_click=lambda: _copy_text(state.whisper_text, 'Whisper')).props('dense flat').classes('text-xs')
+                    ui.button('Cut', on_click=lambda: _cut_text(state, 'whisper', whisper_area)).props('dense flat').classes('text-xs')
 
             whisper_area = ui.textarea(
                 placeholder='Whisper transcription will appear here...'
@@ -36,7 +41,12 @@ def create_output_panels(state: AppState):
         with ui.column().classes('flex-grow'):
             with ui.row().classes('items-center justify-between w-full mb-1'):
                 ui.label('AI Output').classes('font-bold')
-                ai_count = ui.label('0 chars, 0 words').classes('text-xs text-gray-500')
+
+                # Count and buttons
+                with ui.row().classes('items-center gap-2'):
+                    ai_count = ui.label('0 chars, 0 words').classes('text-xs text-gray-500')
+                    ui.button('Copy', on_click=lambda: _copy_text(state.ai_text, 'AI')).props('dense flat').classes('text-xs')
+                    ui.button('Cut', on_click=lambda: _cut_text(state, 'ai', ai_area)).props('dense flat').classes('text-xs')
 
             ai_area = ui.textarea(
                 placeholder='AI processed text will appear here...'
@@ -46,7 +56,12 @@ def create_output_panels(state: AppState):
         with ui.column().classes('flex-grow'):
             with ui.row().classes('items-center justify-between w-full mb-1'):
                 ui.label('Translation Output').classes('font-bold')
-                trans_count = ui.label('0 chars, 0 words').classes('text-xs text-gray-500')
+
+                # Count and buttons
+                with ui.row().classes('items-center gap-2'):
+                    trans_count = ui.label('0 chars, 0 words').classes('text-xs text-gray-500')
+                    ui.button('Copy', on_click=lambda: _copy_text(state.translation_text, 'Translation')).props('dense flat').classes('text-xs')
+                    ui.button('Cut', on_click=lambda: _cut_text(state, 'translation', trans_area)).props('dense flat').classes('text-xs')
 
             trans_area = ui.textarea(
                 placeholder='Translation will appear here...'
@@ -75,3 +90,57 @@ def create_output_panels(state: AppState):
         ui.timer(0.2, update_outputs)
 
     return output_container
+
+
+def _copy_text(text: str, label: str):
+    """Copy text to clipboard."""
+    if not text.strip():
+        ui.notify(f'No {label} text to copy', type='warning')
+        return
+
+    try:
+        # Use JavaScript clipboard API via ui.run_javascript
+        import json
+        escaped_text = json.dumps(text)
+        ui.run_javascript(f'navigator.clipboard.writeText({escaped_text})')
+        ui.notify(f'✓ Copied {label} text to clipboard', type='positive')
+    except Exception as e:
+        ui.notify(f'Clipboard error: {e}', type='negative')
+
+
+def _cut_text(state: AppState, text_type: str, textarea):
+    """Cut text (copy and clear)."""
+    # Get the text
+    if text_type == 'whisper':
+        text = state.whisper_text
+    elif text_type == 'ai':
+        text = state.ai_text
+    elif text_type == 'translation':
+        text = state.translation_text
+    else:
+        return
+
+    if not text.strip():
+        ui.notify(f'No text to cut', type='warning')
+        return
+
+    try:
+        # Copy to clipboard
+        import json
+        escaped_text = json.dumps(text)
+        ui.run_javascript(f'navigator.clipboard.writeText({escaped_text})')
+
+        # Clear the text
+        if text_type == 'whisper':
+            state.whisper_text = ""
+        elif text_type == 'ai':
+            state.ai_text = ""
+        elif text_type == 'translation':
+            state.translation_text = ""
+
+        # Update textarea
+        textarea.value = ""
+
+        ui.notify(f'✓ Cut text to clipboard', type='positive')
+    except Exception as e:
+        ui.notify(f'Clipboard error: {e}', type='negative')
diff --git a/src/whispering_ui/components/sidebar.py b/src/whispering_ui/components/sidebar.py
index 4e6b048..fac0415 100644
--- a/src/whispering_ui/components/sidebar.py
+++ b/src/whispering_ui/components/sidebar.py
@@ -1,7 +1,7 @@
 #!/usr/bin/env python3
 """
-Simplified Sidebar Component
-Control panel with all settings and controls - uses direct state updates
+Complete Sidebar Component
+Control panel with all settings and controls
 """
 
 from nicegui import ui
@@ -12,7 +12,7 @@ from whispering_ui.bridge import ProcessingBridge
 
 def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=None):
     """
-    Create the sidebar control panel with simplified bindings.
+    Create the sidebar control panel with all features.
 
     Args:
         state: Application state
@@ -51,7 +51,7 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
         ui.separator().classes('my-2')
 
         # === MODEL SECTION ===
-        ui.label('Model Settings').classes('font-bold')
+        ui.label('Model Settings Speech-to-Text').classes('font-bold')
 
         with ui.row().classes('items-center w-full gap-2'):
             ui.label('Model:').classes('text-sm w-12')
@@ -104,16 +104,21 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
         # === AI SECTION ===
         ui.label('AI Processing').classes('font-bold')
 
+        # Enable AI checkbox
         ai_cb = ui.checkbox('Enable AI', value=state.ai_enabled)
         ai_cb.on_value_change(lambda e: setattr(state, 'ai_enabled', e.value))
         if not state.ai_available:
             ai_cb.disable()
 
+        # Task selection (persona)
+        task_select = None
+        ai_model_combo = None
         if state.ai_available:
             try:
                 from ai_config import load_ai_config
                 ai_config = load_ai_config()
                 if ai_config:
+                    # Task (persona) selection
                     personas = ai_config.get_personas()
                     persona_names = [p['name'] for p in personas]
 
@@ -125,12 +130,84 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
                         ).classes('flex-grow')
                         task_select.on_value_change(lambda e: setattr(state, 'ai_persona_index',
                                                     persona_names.index(e.value) if e.value in persona_names else 0))
-            except:
-                pass
 
-        ai_trans_cb = ui.checkbox('Translate output', value=state.ai_translate)
-        ai_trans_cb.on_value_change(lambda e: setattr(state, 'ai_translate', e.value))
-        if not state.ai_available:
+                    # Translate output and Translate Only checkboxes
+                    with ui.row().classes('items-center w-full gap-2'):
+                        ai_trans_cb = ui.checkbox('Translate output', value=state.ai_translate)
+                        ai_trans_cb.on_value_change(lambda e: setattr(state, 'ai_translate', e.value))
+
+                        ai_trans_only_cb = ui.checkbox('Translate Only (1:1)', value=state.ai_translate_only)
+                        ai_trans_only_cb.on_value_change(lambda e: setattr(state, 'ai_translate_only', e.value))
+
+                    # Model selection
+                    models = ai_config.get_models()
+                    model_names = [m['name'] for m in models]
+
+                    with ui.row().classes('items-center w-full gap-2'):
+                        ui.label('Model:').classes('text-sm w-12')
+                        ai_model_combo = ui.select(
+                            options=model_names,
+                            value=model_names[min(state.ai_model_index, len(model_names)-1)]
+                        ).classes('flex-grow')
+                        ai_model_combo.on_value_change(lambda e: setattr(state, 'ai_model_index',
+                                                       model_names.index(e.value) if e.value in model_names else 0))
+
+                    # Trigger controls - Manual and Automatic side by side
+                    with ui.row().classes('items-center w-full gap-2'):
+                        # Left side: Manual mode
+                        with ui.column().classes('gap-1'):
+                            ai_manual_cb = ui.checkbox('Manual mode', value=state.ai_manual_mode)
+                            ai_manual_cb.on_value_change(lambda e: _on_manual_mode_changed(state, e.value, ai_process_btn, ai_trigger_select, ai_interval_select, ai_words_num))
+
+                            ai_process_btn = ui.button('⚡ Process Now', on_click=lambda: bridge.manual_ai_trigger()).classes('w-32')
+                            ai_process_btn.set_enabled(state.ai_manual_mode)
+
+                        # Right side: Automatic triggers
+                        with ui.column().classes('gap-1 flex-grow'):
+                            with ui.row().classes('items-center gap-2'):
+                                ui.label('Trigger:').classes('text-sm')
+                                ai_trigger_select = ui.select(
+                                    options=["Time", "Words"],
+                                    value=state.ai_trigger_mode.capitalize()
+                                ).classes('w-20')
+                                ai_trigger_select.on_value_change(lambda e: _on_trigger_changed(state, e.value, ai_interval_select, ai_words_num))
+                                ai_trigger_select.set_enabled(not state.ai_manual_mode)
+
+                            # Interval/Words control (shows one at a time)
+                            with ui.row().classes('items-center gap-2'):
+                                interval_labels = ["5s", "10s", "15s", "20s", "25s", "30s", "45s", "1m", "1.5m", "2m"]
+                                interval_values = [5, 10, 15, 20, 25, 30, 45, 60, 90, 120]
+                                interval_map = dict(zip(interval_labels, interval_values))
+
+                                # Find current label
+                                current_label = "20s"
+                                for lbl, val in interval_map.items():
+                                    if val == state.ai_process_interval:
+                                        current_label = lbl
+                                        break
+
+                                ui.label('Interval:').classes('text-sm')
+                                ai_interval_select = ui.select(
+                                    options=interval_labels,
+                                    value=current_label
+                                ).classes('w-16')
+                                def on_interval_change(e):
+                                    state.ai_process_interval = interval_map.get(e.value, 20)
+                                ai_interval_select.on_value_change(on_interval_change)
+                                ai_interval_select.set_enabled(not state.ai_manual_mode)
+                                ai_interval_select.set_visibility(state.ai_trigger_mode == "time")
+
+                                ui.label('words:').classes('text-sm')
+                                ai_words_num = ui.number(value=state.ai_process_words, min=50, max=500, step=50, format='%.0f').classes('w-20')
+                                ai_words_num.on_value_change(lambda e: setattr(state, 'ai_process_words', int(e.value or 150)))
+                                ai_words_num.set_enabled(not state.ai_manual_mode)
+                                ai_words_num.set_visibility(state.ai_trigger_mode == "words")
+
+            except Exception as e:
+                print(f"Error loading AI config: {e}")
+        else:
+            # AI not available - show disabled controls
+            ai_trans_cb = ui.checkbox('Translate output', value=False)
             ai_trans_cb.disable()
 
         ui.separator().classes('my-2')
@@ -138,11 +215,55 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
         # === TTS SECTION ===
         ui.label('Text-to-Speech').classes('font-bold')
 
+        # Enable TTS checkbox
         tts_cb = ui.checkbox('Enable TTS', value=state.tts_enabled)
         tts_cb.on_value_change(lambda e: setattr(state, 'tts_enabled', e.value))
         if not state.tts_available:
             tts_cb.disable()
 
+        if state.tts_available:
+            # Source selection (W/A/T mutually exclusive checkboxes)
+            with ui.row().classes('items-center w-full gap-2'):
+                ui.label('Source:').classes('text-sm')
+
+                tts_w_cb = ui.checkbox('W', value=(state.tts_source == "whisper"))
+                tts_w_cb.on_value_change(lambda e: _on_tts_source_changed(state, "whisper", e.value, tts_w_cb, tts_a_cb, tts_t_cb))
+
+                tts_a_cb = ui.checkbox('A', value=(state.tts_source == "ai"))
+                tts_a_cb.on_value_change(lambda e: _on_tts_source_changed(state, "ai", e.value, tts_w_cb, tts_a_cb, tts_t_cb))
+
+                tts_t_cb = ui.checkbox('T', value=(state.tts_source == "translation"))
+                tts_t_cb.on_value_change(lambda e: _on_tts_source_changed(state, "translation", e.value, tts_w_cb, tts_a_cb, tts_t_cb))
+
+            # Voice selection
+            with ui.row().classes('items-center w-full gap-2'):
+                ui.label('Voice:').classes('text-sm')
+                tts_voice_label = ui.label(state.tts_voice_display_name).classes('flex-grow text-gray-600')
+
+                tts_browse_btn = ui.button('Browse', on_click=lambda: _browse_voice(state, bridge, tts_voice_label)).classes('text-xs')
+                tts_clear_btn = ui.button('Clear', on_click=lambda: _clear_voice(state, bridge, tts_voice_label)).classes('text-xs')
+
+            # Output options
+            with ui.row().classes('items-center w-full gap-2'):
+                ui.label('Output:').classes('text-sm')
+                tts_save_cb = ui.checkbox('Save to file', value=state.tts_save_file)
+                tts_save_cb.on_value_change(lambda e: setattr(state, 'tts_save_file', e.value))
+
+                tts_format_select = ui.select(options=["wav", "ogg"], value=state.tts_format).classes('w-20')
+                tts_format_select.on_value_change(lambda e: setattr(state, 'tts_format', e.value))
+
+            # TTS status
+            tts_status_label = ui.label('').classes('text-xs text-blue-600')
+
+            # Update TTS status periodically
+            def update_tts_status():
+                if state.tts_status_message:
+                    tts_status_label.text = state.tts_status_message
+                else:
+                    tts_status_label.text = ''
+
+            ui.timer(0.2, update_tts_status)
+
         ui.separator().classes('my-2')
 
         # === CONTROL SECTION ===
@@ -180,7 +301,7 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
                 control_btn.text = 'Start'
                 control_btn.props('color=primary')
 
-            # Update level
+            # Update level - NO smoothing, direct value
             level_progress.value = state.audio_level / 100.0
 
             # Update status
@@ -191,7 +312,9 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
             else:
                 status_label.text = ''
 
-        ui.timer(0.1, update_ui)
+        ui.timer(0.05, update_ui)  # Faster update for audio level (50ms instead of 100ms)
+
+    return sidebar_container
 
 
 def _toggle_text(state: AppState, btn, output_container):
@@ -212,3 +335,60 @@ def _toggle_recording(state: AppState, bridge: ProcessingBridge, btn, level, sta
         bridge.stop_recording()
     else:
         bridge.start_recording()
+
+
+def _on_manual_mode_changed(state: AppState, manual_mode: bool, process_btn, trigger_select, interval_select, words_num):
+    """Handle manual mode checkbox change."""
+    state.ai_manual_mode = manual_mode
+
+    # Enable/disable controls
+    process_btn.set_enabled(manual_mode)
+    trigger_select.set_enabled(not manual_mode)
+    interval_select.set_enabled(not manual_mode and state.ai_trigger_mode == "time")
+    words_num.set_enabled(not manual_mode and state.ai_trigger_mode == "words")
+
+
+def _on_trigger_changed(state: AppState, trigger_mode: str, interval_select, words_num):
+    """Handle trigger mode change."""
+    state.ai_trigger_mode = trigger_mode.lower()
+
+    # Show/hide appropriate control
+    if trigger_mode == "Time":
+        interval_select.set_visibility(True)
+        words_num.set_visibility(False)
+    else:  # Words
+        interval_select.set_visibility(False)
+        words_num.set_visibility(True)
+
+
+def _on_tts_source_changed(state: AppState, source: str, checked: bool, w_cb, a_cb, t_cb):
+    """Handle TTS source selection (mutually exclusive)."""
+    if not checked:
+        # Don't allow unchecking - must have one selected
+        return
+
+    state.tts_source = source
+
+    # Update checkboxes to be mutually exclusive
+    w_cb.value = (source == "whisper")
+    a_cb.value = (source == "ai")
+    t_cb.value = (source == "translation")
+
+
+def _browse_voice(state: AppState, bridge: ProcessingBridge, voice_label):
+    """Browse for voice reference file."""
+    # For now, just show a message - file dialog requires additional implementation
+    ui.notify("Voice browsing not yet implemented in web UI")
+    # TODO: Implement file upload for voice reference
+
+
+def _clear_voice(state: AppState, bridge: ProcessingBridge, voice_label):
+    """Clear voice reference."""
+    state.tts_voice_reference = None
+    state.tts_voice_display_name = "Default"
+    voice_label.text = "Default"
+
+    if bridge.tts_controller:
+        bridge.tts_controller.set_reference_voice(None)
+
+    ui.notify("Voice cleared - using default")
diff --git a/src/whispering_ui/main.py b/src/whispering_ui/main.py
index 4da8b47..7728bf4 100644
--- a/src/whispering_ui/main.py
+++ b/src/whispering_ui/main.py
@@ -85,6 +85,7 @@ def main():
         state.tts_enabled = settings.get("tts_enabled", False)
         state.tts_source = settings.get("tts_source", "whisper")
         state.tts_save_file = settings.get("tts_save_file", False)
+        state.tts_format = settings.get("tts_format", "wav")
     except Exception as e:
         print(f"TTS features not available: {e}")
         state.tts_available = False
@@ -159,6 +160,7 @@ def main():
             settings.set("tts_enabled", state.tts_enabled)
             settings.set("tts_source", state.tts_source)
             settings.set("tts_save_file", state.tts_save_file)
+            settings.set("tts_format", state.tts_format)
 
         settings.save()
 
diff --git a/src/whispering_ui/state.py b/src/whispering_ui/state.py
index e2ff075..850981e 100644
--- a/src/whispering_ui/state.py
+++ b/src/whispering_ui/state.py
@@ -46,6 +46,8 @@ class AppState:
     tts_save_file: bool = False
     tts_format: str = "wav"
     tts_voice_reference: Optional[str] = None
+    tts_voice_display_name: str = "Default"  # Display name for voice
+    tts_status_message: str = ""  # TTS status message
 
     # === Autotype Settings ===
     autotype_mode: str = "Off"  # "Off", "Whisper", "Translation", "AI"