← Whispering

fix: stabilize whisper stream and lock AI/TTS controls

3bbb24a87206066d178baf8bd85b0abcb62dd809 · ovidiucs · Dec 2, 2025 00:58

+151 −98 · 4 files changed

  • src/whispering_ui/bridge.py +58 −60
  • src/whispering_ui/components/output.py +2 −2
  • src/whispering_ui/components/sidebar.py +76 −36
  • src/whispering_ui/main.py +15 −0
diff --git a/src/whispering_ui/bridge.py b/src/whispering_ui/bridge.py
index 1a2c5cd..2d4c2a1 100644
--- a/src/whispering_ui/bridge.py
+++ b/src/whispering_ui/bridge.py
@@ -225,78 +225,42 @@ class ProcessingBridge:
             res = self.ts_queue.get()
             if res:
                 done, curr = res
-                done_text = done or ""
-                curr_text = curr or ""
-                new_text = ""
-
-                if done_text:
-                    prev_committed = self._whisper_committed if len(done_text) >= len(self._whisper_committed) else ""
-                    if len(done_text) > len(prev_committed):
-                        new_text = done_text[len(prev_committed):]
-                    self._whisper_committed = done_text
-
-                    if new_text:
-                        if self.state.tts_enabled and self.state.tts_source == "whisper":
-                            self.tts_session_text += new_text + " "
-
-                        if self.state.autotype_mode == "Whisper" and new_text.strip():
-                            self._autotype_text(new_text)
-
-                preview = self._whisper_committed + curr_text
-                if preview != self.state.whisper_text:
-                    self.state.whisper_text = preview
+                self._update_text_buffer(
+                    done_text=done,
+                    curr_text=curr,
+                    committed_attr='_whisper_committed',
+                    state_attr='whisper_text',
+                    tts_source='whisper',
+                    autotype_mode='Whisper'
+                )
 
         # Poll translation queue (Queue wraps PairDeque)
         while self.tl_queue:
             res = self.tl_queue.get()
             if res:
                 done, curr = res
-                done_text = done or ""
-                curr_text = curr or ""
-                new_text = ""
-
-                if done_text:
-                    prev_committed = self._translation_committed if len(done_text) >= len(self._translation_committed) else ""
-                    if len(done_text) > len(prev_committed):
-                        new_text = done_text[len(prev_committed):]
-                    self._translation_committed = done_text
-
-                    if new_text:
-                        if self.state.tts_enabled and self.state.tts_source == "translation":
-                            self.tts_session_text += new_text + " "
-
-                        if self.state.autotype_mode == "Translation" and new_text.strip():
-                            self._autotype_text(new_text)
-
-                preview = self._translation_committed + curr_text
-                if preview != self.state.translation_text:
-                    self.state.translation_text = preview
+                self._update_text_buffer(
+                    done_text=done,
+                    curr_text=curr,
+                    committed_attr='_translation_committed',
+                    state_attr='translation_text',
+                    tts_source='translation',
+                    autotype_mode='Translation'
+                )
 
         # Poll AI proofread queue (Queue wraps PairDeque)
         while self.pr_queue:
             res = self.pr_queue.get()
             if res:
                 done, curr = res
-                done_text = done or ""
-                curr_text = curr or ""
-                new_text = ""
-
-                if done_text:
-                    prev_committed = self._ai_committed if len(done_text) >= len(self._ai_committed) else ""
-                    if len(done_text) > len(prev_committed):
-                        new_text = done_text[len(prev_committed):]
-                    self._ai_committed = done_text
-
-                    if new_text:
-                        if self.state.tts_enabled and self.state.tts_source == "ai":
-                            self.tts_session_text += new_text + " "
-
-                        if self.state.autotype_mode == "AI" and new_text.strip():
-                            self._autotype_text(new_text)
-
-                preview = self._ai_committed + curr_text
-                if preview != self.state.ai_text:
-                    self.state.ai_text = preview
+                self._update_text_buffer(
+                    done_text=done,
+                    curr_text=curr,
+                    committed_attr='_ai_committed',
+                    state_attr='ai_text',
+                    tts_source='ai',
+                    autotype_mode='AI'
+                )
 
     def _validate_settings(self) -> bool:
         """Validate settings before starting. Returns True if valid."""
@@ -413,6 +377,40 @@ class ProcessingBridge:
         except ImportError:
             self.state.status_message = "autotype.py not found"
 
+    def _update_text_buffer(self, *, done_text: str, curr_text: str, committed_attr: str,
+                            state_attr: str, tts_source: Optional[str], autotype_mode: Optional[str]):
+        """Accumulate finalized text and refresh preview outputs."""
+        done_text = done_text or ""
+        curr_text = curr_text or ""
+
+        committed_value = getattr(self, committed_attr)
+        new_segment = ""
+
+        if done_text:
+            if committed_value and done_text.startswith(committed_value):
+                new_segment = done_text[len(committed_value):]
+                setattr(self, committed_attr, done_text)
+            elif committed_value and committed_value.endswith(done_text):
+                new_segment = ""
+            else:
+                setattr(self, committed_attr, committed_value + done_text)
+                new_segment = done_text
+
+            committed_value = getattr(self, committed_attr)
+        else:
+            committed_value = getattr(self, committed_attr)
+
+        preview = committed_value + curr_text
+        if getattr(self.state, state_attr) != preview:
+            setattr(self.state, state_attr, preview)
+
+        if new_segment:
+            if tts_source and self.state.tts_enabled and self.state.tts_source == tts_source:
+                self.tts_session_text += new_segment + " "
+
+            if autotype_mode and self.state.autotype_mode == autotype_mode and new_segment.strip():
+                self._autotype_text(new_segment)
+
     def manual_ai_trigger(self):
         """Manually trigger AI processing."""
         if not self.state.is_recording:
diff --git a/src/whispering_ui/components/output.py b/src/whispering_ui/components/output.py
index dd5cd40..ce68cfd 100644
--- a/src/whispering_ui/components/output.py
+++ b/src/whispering_ui/components/output.py
@@ -55,10 +55,10 @@ def create_output_panels(state: AppState):
     count_labels = {}
 
     with output_container:
-        stack = ui.column().classes('output-stack w-full h-full flex-1')
+        stack = ui.element('div').classes('output-stack flex flex-col w-full h-full flex-1')
         for config in panel_defs:
             with stack:
-                panel = ui.element('div').classes('output-panel').style('flex: 1; min-height: 0;')
+                panel = ui.element('div').classes('output-panel').style('flex: 1 1 0; min-height: 0;')
                 with panel:
                     with ui.row().classes('panel-header w-full'):
                         ui.label(config['title']).classes('font-bold')
diff --git a/src/whispering_ui/components/sidebar.py b/src/whispering_ui/components/sidebar.py
index a9a20bb..8f6aa68 100644
--- a/src/whispering_ui/components/sidebar.py
+++ b/src/whispering_ui/components/sidebar.py
@@ -116,14 +116,15 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
         # Enable AI checkbox
         ai_cb = ui.checkbox('Enable AI', value=state.ai_enabled).props('dense')
         ai_section = ui.column().classes('w-full gap-1')
+        ai_controls = []
+        ai_process_btn = None
+        ai_trigger_select = None
+        ai_interval_select = None
+        ai_words_num = None
 
-        def on_ai_toggle(e):
-            state.ai_enabled = e.value
-            _set_section_visual_state(ai_section, e.value)
-
-        ai_cb.on_value_change(on_ai_toggle)
-        if not state.ai_available:
-            ai_cb.disable()
+        def register_ai(control):
+            ai_controls.append(control)
+            return control
 
         # AI controls
         if state.ai_available:
@@ -138,19 +139,19 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
 
                         with ui.row().classes('items-center w-full gap-1'):
                             ui.label('Task:').classes('text-xs w-12')
-                            task_select = ui.select(
+                            task_select = register_ai(ui.select(
                                 options=persona_names,
                                 value=persona_names[min(state.ai_persona_index, len(persona_names)-1)]
-                            ).classes('flex-grow').props('dense')
+                            ).classes('flex-grow').props('dense'))
                             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))
 
                         # Translate checkboxes - compact
                         with ui.row().classes('items-center w-full gap-2'):
-                            ai_trans_cb = ui.checkbox('Translate', value=state.ai_translate).props('dense')
+                            ai_trans_cb = register_ai(ui.checkbox('Translate', value=state.ai_translate).props('dense'))
                             ai_trans_cb.on_value_change(lambda e: setattr(state, 'ai_translate', e.value))
 
-                            ai_trans_only_cb = ui.checkbox('Only (1:1)', value=state.ai_translate_only).props('dense')
+                            ai_trans_only_cb = register_ai(ui.checkbox('Only (1:1)', value=state.ai_translate_only).props('dense'))
                             ai_trans_only_cb.on_value_change(lambda e: setattr(state, 'ai_translate_only', e.value))
 
                         # Model selection
@@ -159,26 +160,26 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
 
                         with ui.row().classes('items-center w-full gap-1'):
                             ui.label('Model:').classes('text-xs w-12')
-                            ai_model_combo = ui.select(
+                            ai_model_combo = register_ai(ui.select(
                                 options=model_names,
                                 value=model_names[min(state.ai_model_index, len(model_names)-1)]
-                            ).classes('flex-grow').props('dense')
+                            ).classes('flex-grow').props('dense'))
                             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 - compact layout
-                        ai_manual_cb = ui.checkbox('Manual mode', value=state.ai_manual_mode).props('dense')
+                        ai_manual_cb = register_ai(ui.checkbox('Manual mode', value=state.ai_manual_mode).props('dense'))
 
-                        ai_process_btn = ui.button('⚡ Process Now', on_click=lambda: bridge.manual_ai_trigger()).classes('w-full').props('dense')
+                        ai_process_btn = register_ai(ui.button('⚡ Process Now', on_click=lambda: bridge.manual_ai_trigger()).classes('w-full').props('dense'))
                         ai_process_btn.set_enabled(state.ai_manual_mode)
 
                         # Trigger mode and settings
                         with ui.row().classes('items-center w-full gap-1'):
                             ui.label('Trigger:').classes('text-xs')
-                            ai_trigger_select = ui.select(
+                            ai_trigger_select = register_ai(ui.select(
                                 options=["Time", "Words"],
                                 value=state.ai_trigger_mode.capitalize()
-                            ).classes('w-16').props('dense')
+                            ).classes('w-16').props('dense'))
                             ai_trigger_select.set_enabled(not state.ai_manual_mode)
 
                             # Interval control
@@ -193,10 +194,10 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
                                     break
 
                             ui.label('Int:').classes('text-xs')
-                            ai_interval_select = ui.select(
+                            ai_interval_select = register_ai(ui.select(
                                 options=interval_labels,
                                 value=current_label
-                            ).classes('w-14').props('dense')
+                            ).classes('w-14').props('dense'))
 
                             def on_interval_change(e):
                                 state.ai_process_interval = interval_map.get(e.value, 20)
@@ -206,7 +207,7 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
                             ai_interval_select.set_visibility(state.ai_trigger_mode == "time")
 
                             ui.label('W:').classes('text-xs')
-                            ai_words_num = ui.number(value=state.ai_process_words, min=50, max=500, step=50).classes('w-16').props('dense')
+                            ai_words_num = register_ai(ui.number(value=state.ai_process_words, min=50, max=500, step=50).classes('w-16').props('dense'))
                             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")
@@ -221,7 +222,21 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
                 except Exception as e:
                     print(f"Error loading AI config: {e}")
 
+        def on_ai_toggle(e):
+            state.ai_enabled = e.value
+            active = e.value and state.ai_available
+            _set_section_visual_state(ai_section, active)
+            _set_controls_enabled(ai_controls, active)
+            if active and state.ai_available and all(ctrl is not None for ctrl in (ai_process_btn, ai_trigger_select, ai_interval_select, ai_words_num)):
+                _on_manual_mode_changed(state, state.ai_manual_mode, ai_process_btn, ai_trigger_select, ai_interval_select, ai_words_num)
+                _on_trigger_changed(state, state.ai_trigger_mode.capitalize(), ai_interval_select, ai_words_num)
+
+        ai_cb.on_value_change(on_ai_toggle)
+        if not state.ai_available:
+            ai_cb.disable()
+
         _set_section_visual_state(ai_section, state.ai_enabled and state.ai_available)
+        _set_controls_enabled(ai_controls, state.ai_enabled and state.ai_available)
 
         ui.separator().classes('my-1')
 
@@ -234,14 +249,11 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
         # Enable TTS
         tts_cb = ui.checkbox('Enable TTS', value=state.tts_enabled).props('dense')
         tts_section = ui.column().classes('w-full gap-1')
+        tts_controls = []
 
-        def on_tts_toggle(e):
-            state.tts_enabled = e.value
-            _set_section_visual_state(tts_section, e.value)
-
-        tts_cb.on_value_change(on_tts_toggle)
-        if not state.tts_available:
-            tts_cb.disable()
+        def register_tts(control):
+            tts_controls.append(control)
+            return control
 
         if state.tts_available:
             with tts_section:
@@ -249,9 +261,9 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
                 with ui.row().classes('items-center w-full gap-1'):
                     ui.label('Src:').classes('text-xs w-10')
 
-                    tts_w_cb = ui.checkbox('W', value=(state.tts_source == "whisper")).props('dense')
-                    tts_a_cb = ui.checkbox('A', value=(state.tts_source == "ai")).props('dense')
-                    tts_t_cb = ui.checkbox('T', value=(state.tts_source == "translation")).props('dense')
+                    tts_w_cb = register_tts(ui.checkbox('W', value=(state.tts_source == "whisper")).props('dense'))
+                    tts_a_cb = register_tts(ui.checkbox('A', value=(state.tts_source == "ai")).props('dense'))
+                    tts_t_cb = register_tts(ui.checkbox('T', value=(state.tts_source == "translation")).props('dense'))
 
                     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.on_value_change(lambda e: _on_tts_source_changed(state, "ai", e.value, tts_w_cb, tts_a_cb, tts_t_cb))
@@ -262,23 +274,23 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
                     ui.label('Voice:').classes('text-xs w-10')
                     tts_voice_label = ui.label(state.tts_voice_display_name).classes('flex-grow text-xs text-gray-400 truncate')
 
-                    upload = ui.upload(
+                    upload = register_tts(ui.upload(
                         on_upload=lambda e: _on_voice_upload(e, state, bridge, tts_voice_label),
                         auto_upload=True,
                         max_file_size=50_000_000,
                         max_files=1
-                    ).props('accept=audio/*').classes('hidden')
+                    ).props('accept=audio/*').classes('hidden'))
 
-                    ui.button(icon='folder_open', on_click=lambda u=upload: u.run_method('pickFiles')).props('flat dense round size=sm')
-                    ui.button(icon='clear', on_click=lambda: _clear_voice(state, bridge, tts_voice_label)).props('flat dense round size=sm')
+                    register_tts(ui.button(icon='folder_open', on_click=lambda u=upload: u.run_method('pickFiles')).props('flat dense round size=sm'))
+                    register_tts(ui.button(icon='clear', on_click=lambda: _clear_voice(state, bridge, tts_voice_label)).props('flat dense round size=sm'))
 
                 # Output options - compact
                 with ui.row().classes('items-center w-full gap-1'):
                     ui.label('Out:').classes('text-xs w-10')
-                    tts_save_cb = ui.checkbox('Save', value=state.tts_save_file).props('dense')
+                    tts_save_cb = register_tts(ui.checkbox('Save', value=state.tts_save_file).props('dense'))
                     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-16').props('dense')
+                    tts_format_select = register_tts(ui.select(options=["wav", "ogg"], value=state.tts_format).classes('w-16').props('dense'))
                     tts_format_select.on_value_change(lambda e: setattr(state, 'tts_format', e.value))
 
                 # TTS status - compact
@@ -292,7 +304,18 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
 
                 ui.timer(0.2, update_tts_status)
 
+        def on_tts_toggle(e):
+            state.tts_enabled = e.value
+            active = e.value and state.tts_available
+            _set_section_visual_state(tts_section, active)
+            _set_controls_enabled(tts_controls, active)
+
+        tts_cb.on_value_change(on_tts_toggle)
+        if not state.tts_available:
+            tts_cb.disable()
+
         _set_section_visual_state(tts_section, state.tts_enabled and state.tts_available)
+        _set_controls_enabled(tts_controls, state.tts_enabled and state.tts_available)
 
         ui.separator().classes('my-1')
 
@@ -482,3 +505,20 @@ def _set_section_visual_state(section, enabled: bool):
         section.classes(remove='section-muted')
     else:
         section.classes(add='section-muted')
+
+
+def _set_controls_enabled(controls, enabled: bool):
+    """Enable or disable a list of controls."""
+    for ctrl in controls:
+        if ctrl is None:
+            continue
+        try:
+            ctrl.set_enabled(enabled)
+        except AttributeError:
+            try:
+                (ctrl.enable() if enabled else ctrl.disable())
+            except AttributeError:
+                if enabled:
+                    ctrl.props(remove='disable')
+                else:
+                    ctrl.props('disable')
diff --git a/src/whispering_ui/main.py b/src/whispering_ui/main.py
index 526f418..92b1aae 100644
--- a/src/whispering_ui/main.py
+++ b/src/whispering_ui/main.py
@@ -179,6 +179,20 @@ def main():
                 margin-bottom: 0.25rem;
             }
 
+            .output-panel .q-field {
+                flex: 1 1 0;
+                display: flex;
+                flex-direction: column;
+                min-height: 0;
+            }
+
+            .output-panel .q-field__inner,
+            .output-panel .q-field__control,
+            .output-panel .q-field__native {
+                flex: 1 1 0;
+                min-height: 0;
+            }
+
             .output-textarea {
                 flex: 1 1 0;
                 min-height: 0;
@@ -186,6 +200,7 @@ def main():
                 height: 100%;
                 resize: none;
                 font-family: Menlo, Consolas, 'Liberation Mono', monospace;
+                padding-bottom: 0;
             }
 
             .section-muted {