feat: Add time range selection, periodic saves, and crash recovery
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
@@ -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 @@ -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 @@ -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 @@ -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}") @@ -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 @@ -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)}") @@ -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 = ""