← Whispering

feat: Add time range selection, periodic saves, and crash recovery

a79e6ac17acac6ce51f4ade9508121c3db76b72a · Claude · Jan 26, 2026 06:33

Enhance file transcription with:
- Time range selection (start/end timestamps) for partial file transcription
- Periodic saving with visual indicator showing last saved position and text
- Crash recovery system that detects incomplete sessions and allows resume
- Audio playback preview for scrubbing to specific positions
- Recovery state persisted to logs/file_recovery/recovery_state.json

+608 −30 · 7 files changed

  • src/core.py +3 −2
  • src/core_parts/audio_devices.py +48 −4
  • src/core_parts/processing.py +166 −20
  • src/whispering_ui/bridge.py +201 −0
  • src/whispering_ui/components/help.py +18 −1
  • src/whispering_ui/components/sidebar.py +155 −3
  • src/whispering_ui/state.py +17 −0
diff --git a/src/core.py b/src/core.py
index 2a8bc06..6b1e63a 100755
--- a/src/core.py
+++ b/src/core.py
@@ -5,10 +5,11 @@ from core_parts.audio_devices import (
     TARGET_SAMPLE_RATE, SAMPLE_WIDTH, CHUNK_DURATION,
     get_preferred_hostapi_index, get_mic_names, get_default_device_index,
     get_mic_index, get_device_info, audio_to_wav_bytes, resample_to_mono_16k,
-    load_audio_file, get_audio_files_from_directory, is_audio_file
+    load_audio_file, get_audio_files_from_directory, is_audio_file, get_audio_duration
 )
 from core_parts.processing import (
-    translate, parse_ai_proofread_translate, ai_translate, proc, proc_file
+    translate, parse_ai_proofread_translate, ai_translate, proc, proc_file,
+    load_recovery_state, clear_recovery_state
 )
 
 # Re-export constants
diff --git a/src/core_parts/audio_devices.py b/src/core_parts/audio_devices.py
index 697deb0..d7587a5 100644
--- a/src/core_parts/audio_devices.py
+++ b/src/core_parts/audio_devices.py
@@ -159,7 +159,31 @@ def resample_to_mono_16k(data, orig_rate, orig_channels):
     return audio.tobytes()
 
 
-def load_audio_file(file_path: str) -> tuple:
+def get_audio_duration(file_path: str) -> float:
+    """
+    Get the duration of an audio file without loading all data.
+
+    Args:
+        file_path: Path to the audio file
+
+    Returns:
+        Duration in seconds
+
+    Raises:
+        FileNotFoundError: If file doesn't exist
+        ValueError: If file format is not supported
+    """
+    if not os.path.exists(file_path):
+        raise FileNotFoundError(f"Audio file not found: {file_path}")
+
+    try:
+        info = sf.info(file_path)
+        return info.duration
+    except Exception as e:
+        raise ValueError(f"Failed to get audio duration: {str(e)}")
+
+
+def load_audio_file(file_path: str, start_time: float = 0.0, end_time: float = None) -> tuple:
     """
     Load an audio file and return audio data ready for Whisper.
 
@@ -167,6 +191,8 @@ def load_audio_file(file_path: str) -> tuple:
 
     Args:
         file_path: Path to the audio file
+        start_time: Start timestamp in seconds (default: 0.0)
+        end_time: End timestamp in seconds (default: None = end of file)
 
     Returns:
         tuple: (audio_data_bytes, sample_rate, duration_seconds)
@@ -179,8 +205,26 @@ def load_audio_file(file_path: str) -> tuple:
         raise FileNotFoundError(f"Audio file not found: {file_path}")
 
     try:
-        # Read audio file using soundfile (supports many formats)
-        audio_data, sample_rate = sf.read(file_path, dtype='float32')
+        # Get file info first
+        info = sf.info(file_path)
+        file_sample_rate = info.samplerate
+        total_duration = info.duration
+
+        # Calculate frame positions
+        start_frame = int(start_time * file_sample_rate)
+        if end_time is not None:
+            end_frame = int(end_time * file_sample_rate)
+            frames_to_read = end_frame - start_frame
+        else:
+            frames_to_read = -1  # Read to end
+
+        # Read audio file using soundfile with time range
+        audio_data, sample_rate = sf.read(
+            file_path,
+            dtype='float32',
+            start=start_frame,
+            frames=frames_to_read if frames_to_read > 0 else None
+        )
 
         # Get number of channels
         if len(audio_data.shape) == 1:
@@ -188,7 +232,7 @@ def load_audio_file(file_path: str) -> tuple:
         else:
             channels = audio_data.shape[1]
 
-        # Calculate duration
+        # Calculate actual duration of loaded segment
         duration = len(audio_data) / sample_rate
 
         # Convert to int16
diff --git a/src/core_parts/processing.py b/src/core_parts/processing.py
index 1d1b08d..3c2aab2 100644
--- a/src/core_parts/processing.py
+++ b/src/core_parts/processing.py
@@ -477,7 +477,7 @@ def proc(index, model, vad, memory, patience, timeout, prompt, source, target, t
         ready[0] = None
 
 
-def proc_file(file_paths, model, vad, source, tsres_queue, ready, device="cpu", error=None, progress=None, current_file=None, para_detect=True, para_threshold_std=1.5, para_min_pause=0.8, para_max_chars=500, para_max_words=100):
+def proc_file(file_paths, model, vad, source, tsres_queue, ready, device="cpu", error=None, progress=None, current_file=None, para_detect=True, para_threshold_std=1.5, para_min_pause=0.8, para_max_chars=500, para_max_words=100, start_time=0.0, end_time=None, save_queue=None, position_tracker=None):
     """
     Process audio files for transcription (batch mode).
 
@@ -497,11 +497,24 @@ def proc_file(file_paths, model, vad, source, tsres_queue, ready, device="cpu",
         para_min_pause: Minimum pause for paragraph break
         para_max_chars: Max characters before forced paragraph break
         para_max_words: Max words before forced paragraph break
+        start_time: Start timestamp in seconds (default: 0.0)
+        end_time: End timestamp in seconds (default: None = end of file)
+        save_queue: Queue for save notifications (file_path, position, preview_text, timestamp)
+        position_tracker: Container for current position [float] for crash recovery
 
     Output Queue Format:
         - (done_text, "") for each completed file
         - None when all files are processed
+
+    Save Queue Format:
+        - (file_path, position_seconds, preview_text, timestamp_str) for each save
+        - None when processing ends
     """
+    import os
+    import json
+    from datetime import datetime
+    from pathlib import Path
+
     # Create paragraph detector if enabled
     para_detector = ParagraphDetector(
         threshold_std=para_threshold_std,
@@ -510,12 +523,52 @@ def proc_file(file_paths, model, vad, source, tsres_queue, ready, device="cpu",
         max_words=para_max_words
     ) if para_detect else None
 
+    # Recovery file path
+    recovery_dir = Path("logs/file_recovery")
+    recovery_dir.mkdir(parents=True, exist_ok=True)
+
+    def save_recovery_state(file_path, position, text_so_far, file_index):
+        """Save current state for crash recovery."""
+        recovery_file = recovery_dir / "recovery_state.json"
+        state = {
+            "file_path": file_path,
+            "file_index": file_index,
+            "position": position,
+            "text_so_far": text_so_far,
+            "timestamp": datetime.now().isoformat(),
+            "model": model,
+            "source": source,
+            "vad": vad
+        }
+        try:
+            with open(recovery_file, 'w') as f:
+                json.dump(state, f)
+        except Exception:
+            pass  # Don't fail transcription due to save error
+
+    def get_preview_text(text, max_words=5):
+        """Get last few words as preview."""
+        words = text.strip().split()
+        if len(words) <= max_words:
+            return text.strip()
+        return "..." + " ".join(words[-max_words:])
+
+    def format_time(seconds):
+        """Format seconds as HH:MM:SS."""
+        hours = int(seconds // 3600)
+        minutes = int((seconds % 3600) // 60)
+        secs = int(seconds % 60)
+        if hours > 0:
+            return f"{hours}:{minutes:02d}:{secs:02d}"
+        return f"{minutes}:{secs:02d}"
+
     try:
         # Load model
         whisper_model = WhisperModel(model, device=device)
         ready[0] = True
 
         total_files = len(file_paths)
+        accumulated_text = ""
 
         for file_index, file_path in enumerate(file_paths):
             # Check if stop requested
@@ -523,17 +576,21 @@ def proc_file(file_paths, model, vad, source, tsres_queue, ready, device="cpu",
                 break
 
             # Update current file name
+            file_name = os.path.basename(file_path)
             if current_file is not None:
-                import os
-                current_file[0] = os.path.basename(file_path)
+                current_file[0] = file_name
 
             # Update progress
             if progress is not None:
                 progress[0] = int((file_index / total_files) * 100)
 
             try:
-                # Load audio file
-                audio_bytes, sample_rate, duration = load_audio_file(file_path)
+                # Load audio file with time range
+                audio_bytes, sample_rate, duration = load_audio_file(
+                    file_path,
+                    start_time=start_time if file_index == 0 else 0.0,
+                    end_time=end_time if file_index == 0 else None
+                )
 
                 # Create WAV in memory for Whisper
                 audio_file = audio_to_wav_bytes(audio_bytes, sample_rate, SAMPLE_WIDTH, channels=1)
@@ -545,37 +602,126 @@ def proc_file(file_paths, model, vad, source, tsres_queue, ready, device="cpu",
                     vad_filter=vad
                 )
 
-                # Collect all segments
-                segments_list = list(segments)
-
-                # Process with paragraph detection if enabled
-                if para_detector and segments_list:
-                    transcription = para_detector.process_segments(segments_list, 0.0)
-                else:
-                    transcription = "".join(segment.text for segment in segments_list)
+                # Process segments incrementally for periodic saves
+                file_text = ""
+                last_save_position = start_time if file_index == 0 else 0.0
+                paragraph_count = 0
 
                 # Add file header
-                import os
-                file_name = os.path.basename(file_path)
-                result_text = f"\n--- {file_name} ---\n{transcription.strip()}\n"
+                header = f"\n--- {file_name} ---\n"
+                tsres_queue.put((header, ""))
+                accumulated_text += header
+
+                for segment in segments:
+                    # Check if stop requested
+                    if ready[0] is False or ready[0] is None:
+                        break
+
+                    segment_text = segment.text
+
+                    # Process with paragraph detection if enabled
+                    if para_detector:
+                        # Check if this segment ends a paragraph
+                        processed = para_detector.process_segments([segment], last_save_position)
+                        if '\n\n' in processed:
+                            paragraph_count += 1
+                    else:
+                        processed = segment_text
+
+                    file_text += processed
+                    accumulated_text += processed
+
+                    # Update position tracker
+                    current_position = (start_time if file_index == 0 else 0.0) + segment.end
+                    if position_tracker is not None:
+                        position_tracker[0] = current_position
+
+                    # Send incremental result
+                    tsres_queue.put((processed, ""))
+
+                    # Save after each paragraph or every 30 seconds of audio
+                    should_save = (
+                        (para_detector and '\n\n' in processed) or
+                        (segment.end - last_save_position >= 30)
+                    )
+
+                    if should_save and save_queue is not None:
+                        preview = get_preview_text(accumulated_text)
+                        timestamp = datetime.now().strftime("%H:%M:%S")
+                        save_queue.put((file_path, current_position, preview, timestamp))
+
+                        # Save recovery state
+                        save_recovery_state(file_path, current_position, accumulated_text, file_index)
+                        last_save_position = segment.end
 
-                # Send result
-                tsres_queue.put((result_text, ""))
+                # Final save for this file
+                if save_queue is not None:
+                    preview = get_preview_text(accumulated_text)
+                    timestamp = datetime.now().strftime("%H:%M:%S")
+                    save_queue.put((file_path, duration, preview, timestamp))
+                    save_recovery_state(file_path, duration, accumulated_text, file_index)
+
+                # Add trailing newline
+                tsres_queue.put(("\n", ""))
+                accumulated_text += "\n"
 
             except Exception as file_error:
                 # Send error for this file but continue with others
-                import os
-                file_name = os.path.basename(file_path)
                 error_text = f"\n--- {file_name} ---\n[Error: {str(file_error)}]\n"
                 tsres_queue.put((error_text, ""))
+                accumulated_text += error_text
 
         # Final progress update
         if progress is not None:
             progress[0] = 100
 
+        # Clear recovery file on successful completion
+        recovery_file = recovery_dir / "recovery_state.json"
+        if recovery_file.exists():
+            try:
+                recovery_file.unlink()
+            except Exception:
+                pass
+
     except Exception as e:
         if error is not None:
             error[0] = str(e)
     finally:
         tsres_queue.put(None)
+        if save_queue is not None:
+            save_queue.put(None)
         ready[0] = None
+
+
+def load_recovery_state():
+    """
+    Load crash recovery state if available.
+
+    Returns:
+        dict with recovery state or None if no recovery available
+    """
+    import json
+    from pathlib import Path
+
+    recovery_file = Path("logs/file_recovery/recovery_state.json")
+    if not recovery_file.exists():
+        return None
+
+    try:
+        with open(recovery_file, 'r') as f:
+            state = json.load(f)
+        return state
+    except Exception:
+        return None
+
+
+def clear_recovery_state():
+    """Clear the recovery state file."""
+    from pathlib import Path
+
+    recovery_file = Path("logs/file_recovery/recovery_state.json")
+    if recovery_file.exists():
+        try:
+            recovery_file.unlink()
+        except Exception:
+            pass
diff --git a/src/whispering_ui/bridge.py b/src/whispering_ui/bridge.py
index aaf8284..86ab4c6 100644
--- a/src/whispering_ui/bridge.py
+++ b/src/whispering_ui/bridge.py
@@ -662,6 +662,11 @@ class ProcessingBridge:
         self.state.error_message = None
         self.state.status_message = f"Starting transcription of {len(file_paths)} file(s)..."
 
+        # Reset save indicators
+        self.state.file_last_saved_text = ""
+        self.state.file_last_saved_time = ""
+        self.state.file_last_saved_position = 0.0
+
         # Clear outputs
         self.clear_outputs()
 
@@ -670,10 +675,18 @@ class ProcessingBridge:
         self._file_error = [None]
         self._file_progress = [0]
         self._file_current = [""]
+        self._file_position = [0.0]  # Current position tracker
+
+        # Save queue for periodic saves
+        self._save_queue = Queue(PairDeque())
 
         # Get source language
         source = None if self.state.source_language == "auto" else self.state.source_language
 
+        # Get time range
+        start_time = self.state.file_start_time
+        end_time = self.state.file_end_time
+
         # Start file processing thread
         threading.Thread(
             target=core.proc_file,
@@ -690,9 +703,25 @@ class ProcessingBridge:
                 self._file_current,
                 self.state.para_detect_enabled
             ),
+            kwargs={
+                'start_time': start_time,
+                'end_time': end_time,
+                'save_queue': self._save_queue,
+                'position_tracker': self._file_position
+            },
             daemon=True
         ).start()
 
+        # Start logging if enabled
+        if self.state.log_enabled:
+            config = self._get_config_for_logging()
+            config['file_transcription'] = True
+            config['file_paths'] = file_paths
+            config['start_time'] = start_time
+            config['end_time'] = end_time
+            request_id = self.session_logger.start_session(config)
+            self.state.current_log_request_id = request_id
+
         # Start polling
         self._start_file_polling()
 
@@ -717,6 +746,17 @@ class ProcessingBridge:
         while self._file_ready[0] is not None:
             time.sleep(0.1)
 
+        # Finalize logging
+        if self.state.log_enabled and self.state.current_log_request_id:
+            outputs = {
+                "whisper_text": self.state.whisper_text,
+                "ai_text": self.state.ai_text,
+                "translation_text": self.state.translation_text
+            }
+            self.session_logger.update_session(outputs)
+            self.session_logger.finalize_session("manual")
+            self.state.current_log_request_id = None
+
         self.state.file_transcription_active = False
         self.state.file_transcription_progress = 0
         self.state.file_transcription_current_file = ""
@@ -737,6 +777,17 @@ class ProcessingBridge:
             self.state.file_transcription_progress = 100
             self.state.status_message = "File transcription complete"
 
+            # Finalize logging
+            if self.state.log_enabled and self.state.current_log_request_id:
+                outputs = {
+                    "whisper_text": self.state.whisper_text,
+                    "ai_text": self.state.ai_text,
+                    "translation_text": self.state.translation_text
+                }
+                self.session_logger.update_session(outputs)
+                self.session_logger.finalize_session("completed")
+                self.state.current_log_request_id = None
+
             # Check for errors
             if self._file_error[0]:
                 self.state.error_message = str(self._file_error[0])
@@ -759,6 +810,21 @@ class ProcessingBridge:
                     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 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
+
     def add_files_for_transcription(self, file_paths: list):
         """Add files to the transcription queue.
 
@@ -790,3 +856,138 @@ class ProcessingBridge:
     def clear_file_list(self):
         """Clear the list of files to transcribe."""
         self.state.file_transcription_paths = []
+        self.state.file_start_time = 0.0
+        self.state.file_end_time = None
+        self.state.file_duration = 0.0
+
+    def set_file_time_range(self, start_time: float, end_time: float = None):
+        """Set the time range for file transcription.
+
+        Args:
+            start_time: Start timestamp in seconds
+            end_time: End timestamp in seconds (None = end of file)
+        """
+        self.state.file_start_time = max(0.0, start_time)
+        self.state.file_end_time = end_time
+
+    def get_file_duration(self, file_path: str) -> float:
+        """Get the duration of an audio file.
+
+        Args:
+            file_path: Path to audio file
+
+        Returns:
+            Duration in seconds
+        """
+        try:
+            duration = core.get_audio_duration(file_path)
+            self.state.file_duration = duration
+            return duration
+        except Exception as e:
+            self.state.error_message = f"Error getting duration: {str(e)}"
+            return 0.0
+
+    def check_recovery_available(self) -> bool:
+        """Check if crash recovery data is available.
+
+        Returns:
+            True if recovery data exists
+        """
+        recovery_state = core.load_recovery_state()
+        if recovery_state:
+            self.state.file_recovery_available = True
+            self.state.file_recovery_path = recovery_state.get('file_path')
+            self.state.file_recovery_position = recovery_state.get('position', 0.0)
+            return True
+        self.state.file_recovery_available = False
+        return False
+
+    def load_recovery_state(self) -> dict:
+        """Load recovery state from crash.
+
+        Returns:
+            Recovery state dict or None
+        """
+        return core.load_recovery_state()
+
+    def apply_recovery(self):
+        """Apply recovery state - set start time to resume position."""
+        recovery_state = core.load_recovery_state()
+        if recovery_state:
+            # Set start time to resume from where we left off
+            self.state.file_start_time = recovery_state.get('position', 0.0)
+
+            # Load the file path if available
+            file_path = recovery_state.get('file_path')
+            if file_path and file_path not in self.state.file_transcription_paths:
+                self.state.file_transcription_paths = [file_path]
+
+            # Load previous text
+            text_so_far = recovery_state.get('text_so_far', '')
+            if text_so_far:
+                self.state.whisper_text = text_so_far
+                self._whisper_committed = text_so_far
+
+            # Clear recovery state
+            core.clear_recovery_state()
+            self.state.file_recovery_available = False
+
+            return True
+        return False
+
+    def discard_recovery(self):
+        """Discard recovery state."""
+        core.clear_recovery_state()
+        self.state.file_recovery_available = False
+        self.state.file_recovery_path = None
+        self.state.file_recovery_position = 0.0
+
+    def play_audio_file(self, file_path: str, start_time: float = 0.0):
+        """Play audio file for scrubbing/preview.
+
+        Args:
+            file_path: Path to audio file
+            start_time: Start position in seconds
+        """
+        if self.state.file_playback_active:
+            self.stop_audio_playback()
+
+        def play_audio():
+            try:
+                import sounddevice as sd
+                import soundfile as sf
+
+                # Read audio file
+                info = sf.info(file_path)
+                start_frame = int(start_time * info.samplerate)
+
+                data, samplerate = sf.read(file_path, start=start_frame)
+
+                self.state.file_playback_active = True
+                self.state.file_playback_position = start_time
+
+                # Play audio
+                sd.play(data, samplerate)
+
+                # Update position during playback
+                while sd.get_stream().active and self.state.file_playback_active:
+                    # Approximate current position
+                    time.sleep(0.1)
+                    self.state.file_playback_position += 0.1
+
+                sd.wait()
+                self.state.file_playback_active = False
+            except Exception as e:
+                print(f"Audio playback error: {e}")
+                self.state.file_playback_active = False
+
+        threading.Thread(target=play_audio, daemon=True).start()
+
+    def stop_audio_playback(self):
+        """Stop audio playback."""
+        try:
+            import sounddevice as sd
+            sd.stop()
+            self.state.file_playback_active = False
+        except Exception as e:
+            print(f"Stop playback error: {e}")
diff --git a/src/whispering_ui/components/help.py b/src/whispering_ui/components/help.py
index b9e89e0..c827941 100644
--- a/src/whispering_ui/components/help.py
+++ b/src/whispering_ui/components/help.py
@@ -74,6 +74,22 @@ See AI_SETUP.md for details.""",
 - Max file size: 500MB per file
 - Multiple files can be added and processed sequentially
 
+**Time Range:** Transcribe specific portion of audio
+- **Start:** Enter start time (M:SS or H:MM:SS)
+- **End:** Enter end time or "end" for full file
+- **Play ▶:** Preview audio from start position
+- **Stop ■:** Stop audio preview
+
+**Save Indicator:** Shows periodic save status
+- Format: "Saved HH:MM:SS: ...last words"
+- Saves after each paragraph or every 30 seconds
+- Data saved to recovery file for crash protection
+
+**Crash Recovery:** If program crashes during transcription
+- Yellow banner shows "Resume from X:XX"
+- **Resume:** Continue from last saved position
+- **Discard:** Start fresh, ignore recovery data
+
 **Transcribe:** Start processing the selected files
 - Uses same model settings as microphone transcription
 - Progress shown with percentage and current file name
@@ -96,7 +112,8 @@ See AI_SETUP.md for details.""",
 - Stop microphone recording before file transcription
 - Large files may take longer depending on model size
 - VAD helps filter silent sections
-- Paragraph detection adds natural line breaks""",
+- Paragraph detection adds natural line breaks
+- Use time range to transcribe specific sections""",
 
     "logging": """**Save logs:** Save session data to structured log files
 
diff --git a/src/whispering_ui/components/sidebar.py b/src/whispering_ui/components/sidebar.py
index 31d7b7a..f89e596 100644
--- a/src/whispering_ui/components/sidebar.py
+++ b/src/whispering_ui/components/sidebar.py
@@ -79,6 +79,27 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
             ui.label('File Transcription').classes('font-bold text-sm')
             ui.button(icon='help_outline', on_click=lambda: show_help_dialog('file_transcription')).props('flat dense round size=sm')
 
+        # Recovery notification (shown if recovery data exists)
+        recovery_row = ui.row().classes('items-center w-full gap-1 bg-yellow-900 p-1 rounded')
+        recovery_row.set_visibility(False)
+
+        with recovery_row:
+            ui.icon('warning', color='yellow').classes('text-sm')
+            recovery_label = ui.label('Recovery available').classes('text-xs text-yellow-200 flex-grow')
+            ui.button('Resume', on_click=lambda: _apply_recovery(state, bridge, recovery_row, update_file_list_display)).props('dense size=sm color=warning')
+            ui.button('Discard', on_click=lambda: _discard_recovery(state, bridge, recovery_row)).props('dense size=sm flat')
+
+        # Check for recovery on load
+        def check_recovery():
+            if bridge.check_recovery_available():
+                recovery_state = bridge.load_recovery_state()
+                if recovery_state:
+                    pos = recovery_state.get('position', 0)
+                    recovery_label.text = f"Resume from {_format_time(pos)}"
+                    recovery_row.set_visibility(True)
+
+        ui.timer(0.5, check_recovery, once=True)
+
         # File selection controls
         file_list_label = ui.label('No files selected').classes('text-xs text-gray-400 truncate w-full')
 
@@ -87,11 +108,19 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
             count = len(state.file_transcription_paths)
             if count == 0:
                 file_list_label.text = 'No files selected'
+                duration_label.text = ''
             elif count == 1:
                 import os
                 file_list_label.text = os.path.basename(state.file_transcription_paths[0])
+                # Get and display duration
+                try:
+                    dur = bridge.get_file_duration(state.file_transcription_paths[0])
+                    duration_label.text = f'Duration: {_format_time(dur)}'
+                except Exception:
+                    duration_label.text = ''
             else:
                 file_list_label.text = f'{count} files selected'
+                duration_label.text = ''
 
         # File upload for multiple files
         def on_file_upload(e):
@@ -134,7 +163,37 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
             ).props('accept=audio/*').classes('hidden')
 
             ui.button('Add Files', on_click=lambda: file_upload.run_method('pickFiles')).classes('flex-grow').props('dense')
-            ui.button(icon='clear', on_click=lambda: _clear_file_list(state, file_list_label, update_file_list_display)).props('flat dense round size=sm')
+            ui.button(icon='clear', on_click=lambda: _clear_file_list(state, bridge, file_list_label, update_file_list_display)).props('flat dense round size=sm')
+
+        # Duration display
+        duration_label = ui.label('').classes('text-xs text-gray-400')
+
+        # Time range controls
+        with ui.row().classes('items-center w-full gap-1'):
+            ui.label('Range:').classes('text-xs w-12')
+
+            # Start time input
+            start_input = ui.input(
+                value='0:00',
+                on_change=lambda e: _parse_time_input(e.value, state, 'start')
+            ).classes('w-16').props('dense')
+            start_input.tooltip('Start time (M:SS or H:MM:SS)')
+
+            ui.label('→').classes('text-xs')
+
+            # End time input
+            end_input = ui.input(
+                value='end',
+                on_change=lambda e: _parse_time_input(e.value, state, 'end')
+            ).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')
 
         # Progress bar for file transcription
         file_progress = ui.linear_progress(value=0, show_value=False).classes('w-full')
@@ -143,6 +202,9 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
         # Current file being processed
         file_current_label = ui.label('').classes('text-xs text-blue-400 truncate w-full')
 
+        # Save indicator - "Saved at: HH:MM:SS ...last words"
+        save_indicator = ui.label('').classes('text-xs text-green-400 truncate w-full')
+
         # Start/Stop file transcription buttons
         with ui.row().classes('items-center w-full gap-1'):
             file_start_btn = ui.button(
@@ -164,12 +226,26 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
                 file_current_label.text = f"Processing: {state.file_transcription_current_file}" if state.file_transcription_current_file else ""
                 file_start_btn.set_enabled(False)
                 file_stop_btn.set_enabled(True)
+
+                # Update save indicator
+                if state.file_last_saved_time:
+                    save_indicator.text = f"Saved {state.file_last_saved_time}: {state.file_last_saved_text}"
+                else:
+                    save_indicator.text = ""
             else:
                 file_progress.set_visibility(state.file_transcription_progress > 0 and state.file_transcription_progress < 100)
                 file_current_label.text = ""
                 file_start_btn.set_enabled(len(state.file_transcription_paths) > 0)
                 file_stop_btn.set_enabled(False)
 
+                # Keep save indicator visible after completion
+                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)
+
         ui.timer(0.2, update_file_ui)
 
         ui.separator().classes('my-1')
@@ -645,9 +721,9 @@ def _set_controls_enabled(controls, enabled: bool):
                     ctrl.props('disable')
 
 
-def _clear_file_list(state: AppState, file_list_label, update_fn):
+def _clear_file_list(state: AppState, bridge: ProcessingBridge, file_list_label, update_fn):
     """Clear the file transcription list."""
-    state.file_transcription_paths = []
+    bridge.clear_file_list()
     update_fn()
     ui.notify("File list cleared")
 
@@ -675,3 +751,79 @@ def _stop_file_transcription(state: AppState, bridge: ProcessingBridge, start_bt
     start_btn.set_enabled(True)
     stop_btn.set_enabled(False)
     ui.notify("File transcription stopped")
+
+
+def _format_time(seconds: float) -> str:
+    """Format seconds as M:SS or H:MM:SS."""
+    if seconds is None:
+        return "end"
+    hours = int(seconds // 3600)
+    minutes = int((seconds % 3600) // 60)
+    secs = int(seconds % 60)
+    if hours > 0:
+        return f"{hours}:{minutes:02d}:{secs:02d}"
+    return f"{minutes}:{secs:02d}"
+
+
+def _parse_time_input(value: str, state: AppState, field: str):
+    """Parse time input and update state.
+
+    Args:
+        value: Time string (M:SS, H:MM:SS, or 'end')
+        state: AppState to update
+        field: 'start' or 'end'
+    """
+    if not value or value.lower() == 'end':
+        if field == 'end':
+            state.file_end_time = None
+        return
+
+    try:
+        parts = value.split(':')
+        if len(parts) == 2:
+            # M:SS format
+            minutes, seconds = int(parts[0]), int(parts[1])
+            total_seconds = minutes * 60 + seconds
+        elif len(parts) == 3:
+            # H:MM:SS format
+            hours, minutes, seconds = int(parts[0]), int(parts[1]), int(parts[2])
+            total_seconds = hours * 3600 + minutes * 60 + seconds
+        else:
+            # Try parsing as seconds
+            total_seconds = float(value)
+
+        if field == 'start':
+            state.file_start_time = max(0.0, total_seconds)
+        else:
+            state.file_end_time = total_seconds if total_seconds > 0 else None
+    except (ValueError, IndexError):
+        pass  # Invalid format, ignore
+
+
+def _apply_recovery(state: AppState, bridge: ProcessingBridge, recovery_row, update_fn):
+    """Apply recovery state."""
+    if bridge.apply_recovery():
+        recovery_row.set_visibility(False)
+        update_fn()
+        ui.notify(f"Recovery applied - starting from {_format_time(state.file_start_time)}", type='positive')
+    else:
+        ui.notify("Failed to apply recovery", type='warning')
+
+
+def _discard_recovery(state: AppState, bridge: ProcessingBridge, recovery_row):
+    """Discard recovery state."""
+    bridge.discard_recovery()
+    recovery_row.set_visibility(False)
+    ui.notify("Recovery discarded")
+
+
+def _play_audio_preview(state: AppState, bridge: ProcessingBridge):
+    """Play audio file for preview/scrubbing."""
+    if not state.file_transcription_paths:
+        return
+
+    file_path = state.file_transcription_paths[0]
+    start_time = state.file_start_time
+
+    bridge.play_audio_file(file_path, start_time)
+    ui.notify(f"Playing from {_format_time(start_time)}")
diff --git a/src/whispering_ui/state.py b/src/whispering_ui/state.py
index 89d2333..7b150d8 100644
--- a/src/whispering_ui/state.py
+++ b/src/whispering_ui/state.py
@@ -72,6 +72,23 @@ class AppState:
     file_transcription_progress: int = 0  # Progress 0-100
     file_transcription_current_file: str = ""  # Currently processing file name
 
+    # === File Transcription Time Range ===
+    file_start_time: float = 0.0  # Start timestamp in seconds
+    file_end_time: Optional[float] = None  # End timestamp (None = end of file)
+    file_duration: float = 0.0  # Total duration of current file
+
+    # === File Transcription Save/Recovery ===
+    file_last_saved_text: str = ""  # Last few words saved (preview)
+    file_last_saved_time: str = ""  # Timestamp of last save (HH:MM:SS)
+    file_last_saved_position: float = 0.0  # Position in seconds when last saved
+    file_recovery_available: bool = False  # True if recovery data exists
+    file_recovery_path: Optional[str] = None  # Path to file being recovered
+    file_recovery_position: float = 0.0  # Position to resume from after crash
+
+    # === File Playback State ===
+    file_playback_active: bool = False  # True when playing audio for scrubbing
+    file_playback_position: float = 0.0  # Current playback position in seconds
+
     # === Text Buffers ===
     whisper_text: str = ""
     ai_text: str = ""