feat: Refactor to modular NiceGUI architecture
This commit refactors the monolithic tkinter-based GUI into a clean, modular NiceGUI architecture with complete separation of concerns. ## Structure - src/ui/state.py: Pure Python data model (no UI dependencies) - src/ui/bridge.py: Logic controller connecting state to core.proc - src/ui/components/sidebar.py: Control panel with all settings - src/ui/components/output.py: Text display panels - src/ui/main.py: Entry point that ties everything together ## Key Improvements 1. **Separation of Concerns**: State, logic, and UI are completely decoupled 2. **Maintainability**: Each module has a single, clear responsibility 3. **Testability**: Pure Python state and bridge can be tested independently 4. **Modern UI**: Material Design-inspired interface via NiceGUI 5. **Flexibility**: Can run as desktop app or web server ## Features Preserved - All existing functionality from gui.py maintained - Settings persistence using same Settings class - Compatible with existing core.proc threading model - AI, TTS, and autotype features all supported ## Dependencies Added - nicegui>=1.4.0: Modern UI framework - pyperclip: Clipboard operations for copy/cut buttons The original gui.py remains unchanged for backward compatibility.
+986 · 9 files changed
-
requirements.txt+4 −0 -
src/ui/README.md+70 −0 -
src/ui/__init__.py+8 −0 -
src/ui/bridge.py+353 −0 -
src/ui/components/__init__.py+9 −0 -
src/ui/components/output.py+72 −0 -
src/ui/components/sidebar.py+208 −0 -
src/ui/main.py+166 −0 -
src/ui/state.py+96 −0
@@ -25,3 +25,7 @@ conformer>=0.3.2 # Optional: for auto-type feature pyautogui +pyperclip + +# NiceGUI for modern UI +nicegui>=1.4.0 @@ -0,0 +1,70 @@ +# NiceGUI UI Module + +This directory contains the modular NiceGUI-based user interface for Whispering. + +## Architecture + +The UI is organized into separate concerns: + +### State (`state.py`) +- **Purpose**: Data model holding all application state +- **Key Features**: + - No UI dependencies (pure Python dataclass) + - Settings, runtime state, and text buffers + - Helper methods for text statistics + +### Bridge (`bridge.py`) +- **Purpose**: Logic controller connecting state to core processing +- **Key Features**: + - Manages threading and queues for `core.proc` + - Polling mechanism using NiceGUI timers + - AI processor initialization + - TTS session management + - Autotype integration + +### Components +- **`sidebar.py`**: Control panel with all settings and controls +- **`output.py`**: Text display panels for Whisper, AI, and Translation output + +### Main (`main.py`) +- **Purpose**: Entry point that ties everything together +- **Key Features**: + - Loads settings from disk + - Initializes state and bridge + - Builds UI layout + - Handles cleanup on exit + +## Running the Application + +To run the NiceGUI version: + +```bash +cd src +python -m ui.main +``` + +Or from the project root: + +```bash +python src/ui/main.py +``` + +## Key Differences from tkinter Version + +1. **Separation of Concerns**: State, logic, and UI are completely decoupled +2. **Reactive Updates**: UI updates automatically when state changes +3. **Modern UI**: Material Design-inspired interface via NiceGUI +4. **Web-based**: Can run as desktop app or web server +5. **Easier Testing**: Pure Python state and bridge can be tested independently + +## Dependencies + +- `nicegui>=1.4.0` - Modern UI framework +- `pyperclip` - Clipboard operations + +## Migration Notes + +- The original `gui.py` used tkinter with tight coupling between UI and logic +- This version maintains the same functionality but with clean separation +- The `core.proc` thread interface remains unchanged +- Settings persistence is maintained using the same `Settings` class @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +""" +NiceGUI UI Package +""" + +from .main import main + +__all__ = ['main'] @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +""" +Bridge Module +Connects UI-agnostic state to core processing logic +""" + +import threading +import time +from typing import Optional +from nicegui import ui + +import core +from cmque import DataDeque, PairDeque +from .state import AppState + + +class ProcessingBridge: + """ + Bridges the UI state with core processing logic. + Manages threads, queues, and polling without UI dependencies. + """ + + def __init__(self, state: AppState): + self.state = state + + # Processing queues (for core.proc) + self.ts_queue = DataDeque() # Whisper transcription queue + self.tl_queue = PairDeque() # Translation queue + self.pr_queue = DataDeque() # AI proofread queue (for proofread+translate mode) + + # Control flags + self.ready = [None] # [None] = stopped, [False] = stopping, [True] = running + self.error = [None] + self.level = [0] # Audio level + self.manual_trigger_requested = [False] # For manual AI trigger + + # Polling timer + self.poll_timer = None + + # AI processor reference + self.ai_processor = None + + # TTS session tracking + self.tts_controller = None + self.tts_session_text = "" + self.tts_session_id = None + + def start_recording(self): + """Start the recording and processing thread.""" + if self.state.is_recording: + return + + # Validate settings + if not self._validate_settings(): + return + + # Reset state + self.ready[0] = False + self.error[0] = None + self.state.error_message = None + self.state.status_message = "Starting..." + self.level[0] = 0 + + # Start new TTS session + self.tts_session_id = time.strftime("%Y%m%d_%H%M%S") + self.tts_session_text = "" + + # Initialize AI processor if enabled + self.ai_processor = self._create_ai_processor() + + # Get mic index + if self.state.mic_index == 0: + mic_index = core.get_default_device_index() + else: + mic_index = self.state.mic_list[self.state.mic_index - 1][0] + + # Get translation target (None if "none") + target = None if self.state.target_language == "none" else self.state.target_language + source = None if self.state.source_language == "auto" else self.state.source_language + + # Get AI processing parameters + ai_trigger_mode = self.state.ai_trigger_mode + ai_process_interval = self.state.ai_process_interval + ai_process_words = self.state.ai_process_words if ai_trigger_mode == "words" else None + + if self.state.ai_manual_mode and self.ai_processor: + ai_trigger_mode = "manual" + ai_process_interval = 999999 + ai_process_words = None + + # Determine which queues to use + prres_queue = None + if self.ai_processor: + if self.ai_processor.mode in ("proofread_translate", "proofread"): + prres_queue = self.pr_queue + + # Start core processing thread + threading.Thread( + target=core.proc, + args=( + mic_index, + self.state.model, + self.state.vad_enabled, + self.state.memory, + self.state.patience, + self.state.timeout, + "", # prompt (removed) + source, + target, + self.ts_queue, # Whisper output queue + self.tl_queue, # Translation output queue + self.ready, + self.state.device, + self.error, + self.level, + self.state.para_detect_enabled + ), + kwargs={ + 'ai_processor': self.ai_processor, + 'ai_process_interval': ai_process_interval, + 'ai_process_words': ai_process_words, + 'ai_trigger_mode': ai_trigger_mode, + 'prres_queue': prres_queue, + 'auto_stop_enabled': self.state.auto_stop_enabled, + 'auto_stop_minutes': self.state.auto_stop_minutes, + 'manual_trigger': self.manual_trigger_requested + }, + daemon=True + ).start() + + # Start polling + self._start_polling() + + # Update state + self.state.is_recording = True + + def stop_recording(self): + """Stop the recording and processing thread.""" + if not self.state.is_recording: + return + + # Signal stop + self.ready[0] = False + self.state.status_message = "Stopping..." + + # Stop polling + if self.poll_timer: + self.poll_timer.deactivate() + self.poll_timer = None + + # Wait for thread to stop (in background) + threading.Thread(target=self._wait_for_stop, daemon=True).start() + + def _wait_for_stop(self): + """Wait for processing thread to stop.""" + while self.ready[0] is not None: + time.sleep(0.1) + + # Finalize TTS session + self._finalize_tts_session() + + # Update state + self.state.is_recording = False + self.state.status_message = "" + self.state.audio_level = 0 + + def _start_polling(self): + """Start polling queues for new data.""" + # Create timer that polls every 100ms + self.poll_timer = ui.timer(0.1, self._poll_queues) + + def _poll_queues(self): + """Poll result queues and update state.""" + # Check if stopped + if self.ready[0] is None: + if self.poll_timer: + self.poll_timer.deactivate() + self.poll_timer = None + self.state.is_recording = False + self.state.audio_level = 0 + + # Check for errors + if self.error[0]: + self.state.error_message = str(self.error[0]) + self.state.status_message = f"Error: {self.error[0]}" + + return + + # Update audio level + self.state.audio_level = min(100, self.level[0]) + + # Poll whisper queue + while self.ts_queue: + text = self.ts_queue.popleft() + self.state.whisper_text += text + + # Handle TTS for Whisper + if self.state.tts_enabled and self.state.tts_source == "whisper": + self.tts_session_text += text + " " + + # Handle autotype for Whisper + if self.state.autotype_mode == "Whisper" and text: + self._autotype_text(text) + + # Poll translation queue + while self.tl_queue: + src, dst = self.tl_queue.popleft() + self.state.translation_text += dst + + # Handle TTS for Translation + if self.state.tts_enabled and self.state.tts_source == "translation": + self.tts_session_text += dst + " " + + # Handle autotype for Translation or AI + if self.state.autotype_mode in ("Translation", "AI") and dst: + self._autotype_text(dst) + + # Poll AI proofread queue + while self.pr_queue: + text = self.pr_queue.popleft() + self.state.ai_text += text + + # Handle TTS for AI + if self.state.tts_enabled and self.state.tts_source == "ai": + self.tts_session_text += text + " " + + def _validate_settings(self) -> bool: + """Validate settings before starting. Returns True if valid.""" + # Check if translation target is required but not set + target = self.state.target_language + + needs_translation = False + if self.state.ai_enabled: + if self.state.ai_translate_only or self.state.ai_translate: + needs_translation = True + + if needs_translation and (target is None or target == "none"): + self.state.error_message = "Please select a target language for translation" + self.state.status_message = "⚠ Please select a target language" + return False + + return True + + def _create_ai_processor(self) -> Optional[object]: + """Create AI processor if AI is enabled.""" + if not self.state.ai_enabled or not self.state.ai_available: + return None + + try: + from ai_config import load_ai_config + from ai_provider import AITextProcessor + + ai_config = load_ai_config() + if not ai_config: + return None + + # Get selected model + models = ai_config.get_models() + if self.state.ai_model_index >= len(models): + return None + selected_model_id = models[self.state.ai_model_index]['id'] + + # Determine mode + if self.state.ai_translate_only: + mode = "translate" + else: + # Get persona from config + personas = ai_config.get_personas() + if self.state.ai_persona_index >= len(personas): + mode = "proofread" + else: + persona_id = personas[self.state.ai_persona_index]['id'] + + if persona_id == "proofread": + if self.state.ai_translate and self.state.target_language != "none": + mode = "proofread_translate" + else: + mode = "proofread" + else: + # Custom persona - treat as proofread + if self.state.ai_translate and self.state.target_language != "none": + mode = "proofread_translate" + else: + mode = "proofread" + + # Get languages + source = None if self.state.source_language == "auto" else self.state.source_language + target = None if self.state.target_language == "none" else self.state.target_language + + # Create processor + processor = AITextProcessor( + config=ai_config, + model_id=selected_model_id, + mode=mode, + source_lang=source, + target_lang=target + ) + + return processor + + except Exception as e: + self.state.error_message = f"AI initialization error: {str(e)[:50]}" + print(f"Failed to initialize AI processor: {e}") + return None + + def _finalize_tts_session(self): + """Finalize TTS session if TTS is enabled.""" + if not self.state.tts_enabled or not self.tts_session_text.strip(): + return + + if not self.tts_controller: + return + + if self.state.tts_save_file and self.tts_session_id: + try: + filename = f"tts_session_{self.tts_session_id}" + self.tts_controller.synthesize_to_file( + text=self.tts_session_text.strip(), + output_filename=filename, + file_format=self.state.tts_format, + async_mode=True + ) + except Exception as e: + print(f"TTS finalization error: {e}") + + # Reset session + self.tts_session_text = "" + + def _autotype_text(self, text: str): + """Auto-type text if autotype module is available.""" + try: + import autotype + + def do_type(): + if not autotype.type_text(text, restore_clipboard=False): + self.state.status_message = "Auto-type failed" + + threading.Thread(target=do_type, daemon=True).start() + except ImportError: + self.state.status_message = "autotype.py not found" + + def manual_ai_trigger(self): + """Manually trigger AI processing.""" + if not self.state.is_recording: + return + + self.manual_trigger_requested[0] = True + if self.state.debug_enabled: + print("[Bridge] Manual AI processing requested", flush=True) + + def refresh_mics(self): + """Refresh the list of available microphones.""" + self.state.mic_list = core.get_mic_names() @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +""" +UI Components Package +""" + +from .sidebar import create_sidebar +from .output import create_output_panels + +__all__ = ['create_sidebar', 'create_output_panels'] @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +""" +Simplified Output Component +Text display panels for Whisper, AI, and Translation output +""" + +from nicegui import ui +from ..state import AppState + + +def create_output_panels(state: AppState): + """ + Create output text panels with simplified updates. + + Args: + state: Application state + """ + + output_container = ui.column().classes('flex-grow p-4 gap-3') + + with output_container: + # === WHISPER OUTPUT === + 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') + + whisper_area = ui.textarea( + placeholder='Whisper transcription will appear here...' + ).classes('w-full').style('min-height: 200px; font-family: monospace;').props('outlined readonly') + + # === AI OUTPUT === + 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') + + ai_area = ui.textarea( + placeholder='AI processed text will appear here...' + ).classes('w-full').style('min-height: 200px; font-family: monospace;').props('outlined readonly') + + # === TRANSLATION OUTPUT === + 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') + + trans_area = ui.textarea( + placeholder='Translation will appear here...' + ).classes('w-full').style('min-height: 200px; font-family: monospace;').props('outlined readonly') + + # Update text areas from state + def update_outputs(): + # Update whisper + if whisper_area.value != state.whisper_text: + whisper_area.value = state.whisper_text + wc, ww = state.get_whisper_count() + whisper_count.text = f'{wc} chars, {ww} words' + + # Update AI + if ai_area.value != state.ai_text: + ai_area.value = state.ai_text + ac, aw = state.get_ai_count() + ai_count.text = f'{ac} chars, {aw} words' + + # Update translation + if trans_area.value != state.translation_text: + trans_area.value = state.translation_text + tc, tw = state.get_translation_count() + trans_count.text = f'{tc} chars, {tw} words' + + ui.timer(0.2, update_outputs) @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +""" +Simplified Sidebar Component +Control panel with all settings and controls - uses direct state updates +""" + +from nicegui import ui +import core +from ..state import AppState +from ..bridge import ProcessingBridge + + +def create_sidebar(state: AppState, bridge: ProcessingBridge): + """ + Create the sidebar control panel with simplified bindings. + + Args: + state: Application state + bridge: Processing bridge + """ + + # Container for all controls + sidebar_container = ui.column().classes('w-96 p-4 gap-2') + + with sidebar_container: + # === MICROPHONE SECTION === + with ui.row().classes('items-center w-full gap-2'): + ui.label('Mic:').classes('text-sm w-12') + mic_display = ["(system default)"] + [name for idx, name in state.mic_list] + mic_select = ui.select( + options=mic_display, + value=mic_display[0] if mic_display else None + ).classes('flex-grow') + mic_select.on_value_change(lambda e: setattr(state, 'mic_index', + mic_select.options.index(e.value) if e.value in mic_select.options else 0)) + + def refresh_mics(): + bridge.refresh_mics() + mic_select.options = ["(system default)"] + [name for idx, name in state.mic_list] + mic_select.update() + + ui.button(icon='refresh', on_click=refresh_mics).props('flat dense').classes('w-10') + + # === TOGGLE TEXT BUTTON === + toggle_btn = ui.button( + 'Show Text ▶' if not state.text_visible else 'Hide Text ◀', + on_click=lambda: _toggle_text(state, toggle_btn) + ).classes('w-full mb-2') + + ui.separator().classes('my-2') + + # === MODEL SECTION === + ui.label('Model Settings').classes('font-bold') + + with ui.row().classes('items-center w-full gap-2'): + ui.label('Model:').classes('text-sm w-12') + model_select = ui.select(options=core.models, value=state.model).classes('flex-grow') + model_select.on_value_change(lambda e: setattr(state, 'model', e.value)) + + # Options + with ui.row().classes('items-center w-full gap-2'): + vad_cb = ui.checkbox('VAD', value=state.vad_enabled) + vad_cb.on_value_change(lambda e: setattr(state, 'vad_enabled', e.value)) + + para_cb = ui.checkbox('¶', value=state.para_detect_enabled) + para_cb.on_value_change(lambda e: setattr(state, 'para_detect_enabled', e.value)) + + ui.label('Dev:').classes('text-sm') + dev_select = ui.select(options=core.devices, value=state.device).classes('w-20') + dev_select.on_value_change(lambda e: setattr(state, 'device', e.value)) + + # Autotype + with ui.row().classes('items-center w-full gap-2'): + ui.label('⌨ Autotype:').classes('text-sm') + auto_select = ui.select( + options=["Off", "Whisper", "Translation", "AI"], + value=state.autotype_mode + ).classes('flex-grow') + auto_select.on_value_change(lambda e: setattr(state, 'autotype_mode', e.value)) + + ui.separator().classes('my-2') + + # === TRANSLATION SECTION === + ui.label('Translation').classes('font-bold') + + with ui.row().classes('items-center w-full gap-2'): + ui.label('Source:').classes('text-sm w-16') + src_select = ui.select( + options=["auto"] + core.sources, + value=state.source_language + ).classes('w-24') + src_select.on_value_change(lambda e: setattr(state, 'source_language', e.value)) + + ui.label('Target:').classes('text-sm w-16') + tgt_select = ui.select( + options=["none"] + core.targets, + value=state.target_language + ).classes('w-24') + tgt_select.on_value_change(lambda e: setattr(state, 'target_language', e.value)) + + ui.separator().classes('my-2') + + # === AI SECTION === + ui.label('AI Processing').classes('font-bold') + + 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() + + if state.ai_available: + try: + from ai_config import load_ai_config + ai_config = load_ai_config() + if ai_config: + personas = ai_config.get_personas() + persona_names = [p['name'] for p in personas] + + with ui.row().classes('items-center w-full gap-2'): + ui.label('Task:').classes('text-sm w-12') + task_select = ui.select( + options=persona_names, + value=persona_names[min(state.ai_persona_index, len(persona_names)-1)] + ).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: + ai_trans_cb.disable() + + ui.separator().classes('my-2') + + # === TTS SECTION === + ui.label('Text-to-Speech').classes('font-bold') + + 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() + + ui.separator().classes('my-2') + + # === CONTROL SECTION === + control_btn = ui.button( + 'Start', + on_click=lambda: _toggle_recording(state, bridge, control_btn, level_progress, status_label) + ).classes('w-full').props('color=primary') + + # Audio level + with ui.row().classes('items-center w-full gap-2'): + ui.label('Level:').classes('text-sm w-12') + level_progress = ui.linear_progress(value=0, show_value=False).classes('flex-grow') + + # Auto-stop + with ui.row().classes('items-center w-full gap-2'): + autostop_cb = ui.checkbox('Auto-stop after', value=state.auto_stop_enabled) + autostop_cb.on_value_change(lambda e: setattr(state, 'auto_stop_enabled', e.value)) + + autostop_num = ui.number(value=state.auto_stop_minutes, min=1, max=60, step=1, + format='%.0f').classes('w-16') + autostop_num.on_value_change(lambda e: setattr(state, 'auto_stop_minutes', int(e.value or 5))) + + ui.label('min').classes('text-sm') + + # Status + status_label = ui.label('').classes('text-sm text-red-600 mt-2') + + # Update UI periodically + def update_ui(): + # Update button + if state.is_recording: + control_btn.text = 'Stop' + control_btn.props('color=negative') + else: + control_btn.text = 'Start' + control_btn.props('color=primary') + + # Update level + level_progress.value = state.audio_level / 100.0 + + # Update status + if state.error_message: + status_label.text = f'Error: {state.error_message}' + elif state.status_message: + status_label.text = state.status_message + else: + status_label.text = '' + + ui.timer(0.1, update_ui) + + +def _toggle_text(state: AppState, btn): + """Toggle text visibility.""" + state.text_visible = not state.text_visible + btn.text = 'Hide Text ◀' if state.text_visible else 'Show Text ▶' + ui.notify(f'Text panels {"shown" if state.text_visible else "hidden"}') + + +def _toggle_recording(state: AppState, bridge: ProcessingBridge, btn, level, status): + """Toggle recording on/off.""" + if state.is_recording: + bridge.stop_recording() + else: + bridge.start_recording() @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +""" +Whispering NiceGUI Application +Main entry point for the NiceGUI-based UI +""" + +import sys +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from nicegui import ui +import core +from settings import Settings +from .state import AppState +from .bridge import ProcessingBridge +from .components.sidebar import create_sidebar +from .components.output import create_output_panels + + +def main(): + """Main application entry point.""" + + # === INITIALIZATION === + # Load settings + settings = Settings() + + # Create application state + state = AppState() + + # Apply loaded settings to state + state.debug_enabled = settings.get("debug_enabled", False) + state.text_visible = settings.get("text_visible", False) + state.model = settings.get("model", "large-v3") + state.vad_enabled = settings.get("vad", True) + state.para_detect_enabled = settings.get("para_detect", True) + state.device = settings.get("device", "cuda") + state.memory = settings.get("memory", 3) + state.patience = settings.get("patience", 5.0) + state.timeout = settings.get("timeout", 5.0) + state.source_language = settings.get("source_language", "auto") + state.target_language = settings.get("target_language", "none") + state.autotype_mode = settings.get("autotype", "Off") + state.auto_stop_enabled = settings.get("auto_stop_enabled", False) + state.auto_stop_minutes = settings.get("auto_stop_minutes", 5) + + # Initialize microphone list + state.mic_list = core.get_mic_names() + + # Check AI availability + try: + from ai_config import load_ai_config + ai_config = load_ai_config() + state.ai_available = ai_config is not None + + if state.ai_available: + # Load AI settings + state.ai_enabled = settings.get("ai_enabled", False) + state.ai_persona_index = settings.get("ai_persona_index", 0) + state.ai_model_index = settings.get("ai_model_index", 0) + state.ai_translate = settings.get("ai_translate", False) + state.ai_translate_only = settings.get("ai_translate_only", False) + state.ai_manual_mode = settings.get("ai_manual_mode", False) + state.ai_trigger_mode = settings.get("ai_trigger_mode", "time") + state.ai_process_interval = settings.get("ai_process_interval", 20) + state.ai_process_words = settings.get("ai_process_words", 150) + except Exception as e: + print(f"AI features not available: {e}") + state.ai_available = False + + # Check TTS availability + try: + from tts_controller import TTSController + state.tts_available = True + + # Load TTS settings + 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) + except Exception as e: + print(f"TTS features not available: {e}") + state.tts_available = False + + # Create processing bridge + bridge = ProcessingBridge(state) + + # Initialize TTS controller in bridge if available + if state.tts_available: + try: + from tts_controller import TTSController + bridge.tts_controller = TTSController(device="auto", output_dir="tts_output") + except Exception as e: + print(f"Failed to initialize TTS controller: {e}") + state.tts_available = False + + # === UI LAYOUT === + ui.page_title('Whispering') + + # Create main container + with ui.row().classes('w-full h-screen'): + # Sidebar (always visible) + create_sidebar(state, bridge) + + # Output panels (conditionally visible) + if state.text_visible: + create_output_panels(state) + + # === SAVE SETTINGS ON EXIT === + def save_settings_on_exit(): + """Save settings before application closes.""" + state.is_shutting_down = True + + # Stop recording if active + if state.is_recording: + bridge.stop_recording() + + # Save all settings + settings.set("text_visible", state.text_visible) + settings.set("debug_enabled", state.debug_enabled) + settings.set("model", state.model) + settings.set("vad", state.vad_enabled) + settings.set("para_detect", state.para_detect_enabled) + settings.set("device", state.device) + settings.set("memory", state.memory) + settings.set("patience", state.patience) + settings.set("timeout", state.timeout) + settings.set("source_language", state.source_language) + settings.set("target_language", state.target_language) + settings.set("autotype", state.autotype_mode) + settings.set("auto_stop_enabled", state.auto_stop_enabled) + settings.set("auto_stop_minutes", state.auto_stop_minutes) + + if state.ai_available: + settings.set("ai_enabled", state.ai_enabled) + settings.set("ai_persona_index", state.ai_persona_index) + settings.set("ai_model_index", state.ai_model_index) + settings.set("ai_translate", state.ai_translate) + settings.set("ai_translate_only", state.ai_translate_only) + settings.set("ai_manual_mode", state.ai_manual_mode) + settings.set("ai_trigger_mode", state.ai_trigger_mode) + settings.set("ai_process_interval", state.ai_process_interval) + settings.set("ai_process_words", state.ai_process_words) + + if state.tts_available: + settings.set("tts_enabled", state.tts_enabled) + settings.set("tts_source", state.tts_source) + settings.set("tts_save_file", state.tts_save_file) + + settings.save() + + # Register cleanup handler + ui.on_shutdown(save_settings_on_exit) + + # === RUN APPLICATION === + # Run as native desktop window + ui.run( + title='Whispering', + native=True, + window_size=(400 if not state.text_visible else 900, 1000), + reload=False + ) + + +if __name__ == "__main__": + main() @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +""" +Application State Model +Holds all application state without UI dependencies +""" + +from dataclasses import dataclass, field +from typing import Optional, List, Tuple + + +@dataclass +class AppState: + """Application state data model - decoupled from UI framework.""" + + # === Microphone Settings === + mic_index: int = 0 + mic_list: List[Tuple[int, str]] = field(default_factory=list) + + # === Model Settings === + model: str = "large-v3" + vad_enabled: bool = True + para_detect_enabled: bool = True + device: str = "cuda" + memory: int = 3 + patience: float = 5.0 + timeout: float = 5.0 + + # === Translation Settings === + source_language: str = "auto" + target_language: str = "none" + + # === AI Settings === + ai_enabled: bool = False + ai_persona_index: int = 0 + ai_model_index: int = 0 + ai_translate: bool = False + ai_translate_only: bool = False + ai_manual_mode: bool = False + ai_trigger_mode: str = "time" # "time" or "words" + ai_process_interval: int = 20 # seconds + ai_process_words: int = 150 + + # === TTS Settings === + tts_enabled: bool = False + tts_source: str = "whisper" # "whisper", "ai", or "translation" + tts_save_file: bool = False + tts_format: str = "wav" + tts_voice_reference: Optional[str] = None + + # === Autotype Settings === + autotype_mode: str = "Off" # "Off", "Whisper", "Translation", "AI" + + # === Auto-stop Settings === + auto_stop_enabled: bool = False + auto_stop_minutes: int = 5 + + # === Runtime State === + is_recording: bool = False + is_shutting_down: bool = False + audio_level: int = 0 + error_message: Optional[str] = None + status_message: str = "" + + # === Text Buffers === + whisper_text: str = "" + ai_text: str = "" + translation_text: str = "" + + # === UI State === + text_visible: bool = False # Start in minimal mode + debug_enabled: bool = False + + # === Feature Availability === + ai_available: bool = False + tts_available: bool = False + + def get_whisper_count(self) -> Tuple[int, int]: + """Get character and word count for Whisper text.""" + text = self.whisper_text.strip() + char_count = len(text) + word_count = len(text.split()) if text else 0 + return char_count, word_count + + def get_ai_count(self) -> Tuple[int, int]: + """Get character and word count for AI text.""" + text = self.ai_text.strip() + char_count = len(text) + word_count = len(text.split()) if text else 0 + return char_count, word_count + + def get_translation_count(self) -> Tuple[int, int]: + """Get character and word count for Translation text.""" + text = self.translation_text.strip() + char_count = len(text) + word_count = len(text.split()) if text else 0 + return char_count, word_count