← Whispering

fix: File transcription output and play/pause toggle

d587cbf54d7105d86d194870743e6b479181f279 · Claude · Jan 26, 2026 07:15

- Fix file transcription not showing output: Use standard queue.Queue
  instead of PairDeque for file transcription. PairDeque was merging
  consecutive results, causing text to be overwritten.

- Add play/pause toggle: Single button now toggles playback. When
  paused, the current position is saved to the start time field,
  allowing easy scrubbing through the audio file.

- Update start time input automatically when playback stops

+93 −34 · 2 files changed

  • src/whispering_ui/bridge.py +55 −25
  • src/whispering_ui/components/sidebar.py +38 −9
diff --git a/src/whispering_ui/bridge.py b/src/whispering_ui/bridge.py
index 86ab4c6..7ef5091 100644
--- a/src/whispering_ui/bridge.py
+++ b/src/whispering_ui/bridge.py
@@ -4,6 +4,7 @@ Bridge Module
 Connects UI-agnostic state to core processing logic
 """
 
+import queue
 import threading
 import time
 from typing import Optional
@@ -677,8 +678,11 @@ class ProcessingBridge:
         self._file_current = [""]
         self._file_position = [0.0]  # Current position tracker
 
+        # Use standard queue for file transcription (not PairDeque which merges results)
+        self._file_ts_queue = queue.Queue()
+
         # Save queue for periodic saves
-        self._save_queue = Queue(PairDeque())
+        self._save_queue = queue.Queue()
 
         # Get source language
         source = None if self.state.source_language == "auto" else self.state.source_language
@@ -695,7 +699,7 @@ class ProcessingBridge:
                 self.state.model,
                 self.state.vad_enabled,
                 source,
-                self.ts_queue,
+                self._file_ts_queue,
                 self._file_ready,
                 self.state.device,
                 self._file_error,
@@ -770,6 +774,9 @@ class ProcessingBridge:
         """Poll result queues for file transcription."""
         # Check if stopped
         if self._file_ready[0] is None:
+            # Drain any remaining items from queue
+            self._drain_file_queue()
+
             if self._file_poll_timer:
                 self._file_poll_timer.deactivate()
                 self._file_poll_timer = None
@@ -800,30 +807,43 @@ class ProcessingBridge:
         self.state.file_transcription_current_file = self._file_current[0]
         self.state.status_message = f"Processing: {self._file_current[0]}" if self._file_current[0] else "Processing..."
 
-        # Poll whisper queue for file transcription results
-        while self.ts_queue:
-            res = self.ts_queue.get()
-            if res:
-                done, curr = res
-                if done:
-                    # Append to whisper text
-                    self.state.whisper_text += done
-                    self._whisper_committed += done
-
-                    # Update logging
-                    if self.state.log_enabled and self.state.current_log_request_id:
-                        outputs = {"whisper_text": self.state.whisper_text}
-                        self.session_logger.update_session(outputs)
+        # Poll file transcription queue (standard queue.Queue)
+        self._drain_file_queue()
 
         # Poll save queue for save notifications
-        if hasattr(self, '_save_queue') and self._save_queue:
-            while self._save_queue:
-                save_info = self._save_queue.get()
-                if save_info:
-                    file_path, position, preview, timestamp = save_info
-                    self.state.file_last_saved_text = preview
-                    self.state.file_last_saved_time = timestamp
-                    self.state.file_last_saved_position = position
+        if hasattr(self, '_save_queue'):
+            try:
+                while True:
+                    save_info = self._save_queue.get_nowait()
+                    if save_info:
+                        file_path, position, preview, timestamp = save_info
+                        self.state.file_last_saved_text = preview
+                        self.state.file_last_saved_time = timestamp
+                        self.state.file_last_saved_position = position
+            except queue.Empty:
+                pass
+
+    def _drain_file_queue(self):
+        """Drain the file transcription queue and update whisper text."""
+        if not hasattr(self, '_file_ts_queue'):
+            return
+
+        try:
+            while True:
+                res = self._file_ts_queue.get_nowait()
+                if res:
+                    done, curr = res
+                    if done:
+                        # Append to whisper text
+                        self.state.whisper_text += done
+                        self._whisper_committed += done
+
+                        # Update logging
+                        if self.state.log_enabled and self.state.current_log_request_id:
+                            outputs = {"whisper_text": self.state.whisper_text}
+                            self.session_logger.update_session(outputs)
+        except queue.Empty:
+            pass
 
     def add_files_for_transcription(self, file_paths: list):
         """Add files to the transcription queue.
@@ -984,10 +1004,20 @@ class ProcessingBridge:
         threading.Thread(target=play_audio, daemon=True).start()
 
     def stop_audio_playback(self):
-        """Stop audio playback."""
+        """Stop audio playback and update start position to current position."""
         try:
             import sounddevice as sd
             sd.stop()
+            # Update start time to where we stopped (for scrubbing)
+            if self.state.file_playback_active:
+                self.state.file_start_time = self.state.file_playback_position
             self.state.file_playback_active = False
         except Exception as e:
             print(f"Stop playback error: {e}")
+
+    def toggle_audio_playback(self, file_path: str, start_time: float = 0.0):
+        """Toggle audio playback - play if stopped, stop if playing."""
+        if self.state.file_playback_active:
+            self.stop_audio_playback()
+        else:
+            self.play_audio_file(file_path, start_time)
diff --git a/src/whispering_ui/components/sidebar.py b/src/whispering_ui/components/sidebar.py
index d857f6e..163a732 100644
--- a/src/whispering_ui/components/sidebar.py
+++ b/src/whispering_ui/components/sidebar.py
@@ -186,12 +186,9 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
             ).classes('w-16').props('dense')
             end_input.tooltip('End time (M:SS, H:MM:SS, or "end")')
 
-            # Play button for scrubbing
-            play_btn = ui.button(icon='play_arrow', on_click=lambda: _play_audio_preview(state, bridge)).props('flat dense round size=sm')
-            play_btn.tooltip('Preview audio')
-
-            stop_play_btn = ui.button(icon='stop', on_click=lambda: bridge.stop_audio_playback()).props('flat dense round size=sm')
-            stop_play_btn.tooltip('Stop preview')
+            # Play/Pause button for scrubbing
+            play_btn = ui.button(icon='play_arrow', on_click=lambda: _toggle_audio_preview(state, bridge, play_btn, start_input)).props('flat dense round size=sm')
+            play_btn.tooltip('Play/Pause preview')
 
         # Progress bar for file transcription
         file_progress = ui.linear_progress(value=0, show_value=False).classes('w-full')
@@ -216,6 +213,9 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
             ).classes('w-16').props('dense color=negative')
             file_stop_btn.set_enabled(False)
 
+        # Track last known playback state to detect changes
+        last_playback_state = [False]
+
         # Update file transcription UI periodically
         def update_file_ui():
             if state.file_transcription_active:
@@ -240,9 +240,20 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
                 if state.file_last_saved_time and state.file_transcription_progress == 100:
                     save_indicator.text = f"Saved {state.file_last_saved_time}: {state.file_last_saved_text}"
 
-            # Update play button state
-            play_btn.set_enabled(len(state.file_transcription_paths) > 0 and not state.file_playback_active)
-            stop_play_btn.set_enabled(state.file_playback_active)
+            # Update play button icon and state
+            play_btn.set_enabled(len(state.file_transcription_paths) > 0)
+            if state.file_playback_active:
+                play_btn._props['icon'] = 'pause'
+                play_btn.update()
+            else:
+                play_btn._props['icon'] = 'play_arrow'
+                play_btn.update()
+
+            # Update start time input when playback stops
+            if last_playback_state[0] and not state.file_playback_active:
+                # Playback just stopped - update start time input
+                start_input.value = _format_time(state.file_start_time)
+            last_playback_state[0] = state.file_playback_active
 
         ui.timer(0.2, update_file_ui)
 
@@ -885,3 +896,21 @@ def _play_audio_preview(state: AppState, bridge: ProcessingBridge):
 
     bridge.play_audio_file(file_path, start_time)
     ui.notify(f"Playing from {_format_time(start_time)}")
+
+
+def _toggle_audio_preview(state: AppState, bridge: ProcessingBridge, play_btn, start_input):
+    """Toggle audio playback - play if stopped, pause if playing."""
+    if not state.file_transcription_paths:
+        return
+
+    file_path = state.file_transcription_paths[0]
+
+    if state.file_playback_active:
+        # Stop playback - position will be updated in bridge.stop_audio_playback
+        bridge.stop_audio_playback()
+        ui.notify(f"Paused at {_format_time(state.file_start_time)}")
+    else:
+        # Start playback from current start time
+        start_time = state.file_start_time
+        bridge.play_audio_file(file_path, start_time)
+        ui.notify(f"Playing from {_format_time(start_time)}")