fix: stabilize whisper stream and lock AI/TTS controls
+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
@@ -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: @@ -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') @@ -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') @@ -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 {