diff --git a/observe/describe.py b/observe/describe.py index b7665a469..6939f7754 100644 --- a/observe/describe.py +++ b/observe/describe.py @@ -28,7 +28,7 @@ from typing import List, Optional import av from PIL import Image, ImageChops, ImageStat -from observe.utils import segment_and_suffix +from observe.utils import get_segment_key from think.callosum import callosum_send from think.utils import setup_cli @@ -340,22 +340,6 @@ class VideoProcessor: contents.append(image) return contents - def _move_to_segment(self, media_path: Path) -> Path: - """Move media file to its segment and return new path.""" - segment, suffix = segment_and_suffix(media_path) - segment_dir = media_path.parent / segment - try: - segment_dir.mkdir(exist_ok=True) - # Preserve the original extension - ext = media_path.suffix - new_path = segment_dir / f"{suffix}{ext}" - media_path.rename(new_path) - logger.info(f"Moved {media_path} to {segment_dir}") - return new_path - except Exception as exc: - logger.error(f"Failed to move {media_path} to segment: {exc}") - return media_path - async def process_with_vision( self, max_concurrent: int = 10, @@ -388,10 +372,8 @@ class VideoProcessor: # Write metadata header to JSONL file with actual video filename if output_file: - from observe.utils import extract_descriptive_suffix - - suffix = extract_descriptive_suffix(self.video_path.stem) - metadata = {"raw": f"{suffix}{self.video_path.suffix}"} + # Files are in segment directories, filename is simple (e.g., center_DP-3_screen.webm) + metadata = {"raw": self.video_path.name} # Add remote origin if set (from sense.py for remote observer uploads) remote = os.getenv("REMOTE_NAME") @@ -682,7 +664,7 @@ class VideoProcessor: all_failed = total_frames > 0 and failed_frames == total_frames if all_failed: - # Don't move video to segment - leave for retry + # Leave video for retry (already in segment dir) error_detail = ( f"Error details in {output_path}" if output_path else "No output file" ) @@ -695,15 +677,10 @@ class VideoProcessor: raise RuntimeError( f"All {total_frames} frame(s) failed vision analysis after retries" ) - else: - # At least some frames succeeded - move to segment - if failed_frames > 0: - logger.warning( - f"{failed_frames}/{total_frames} frame(s) failed processing. " - f"Moving video to segment anyway." - ) - if output_path: - self._move_to_segment(self.video_path) + elif failed_frames > 0: + logger.warning( + f"{failed_frames}/{total_frames} frame(s) failed processing." + ) # Clear qualified_frames to free memory self.qualified_frames.clear() @@ -737,7 +714,7 @@ async def async_main(): parser.add_argument( "video_path", type=str, - help="Path to video file to process", + help="Path to video file in segment directory", ) parser.add_argument( "-j", @@ -751,31 +728,36 @@ async def async_main(): action="store_true", help="Only output frame metadata without vision analysis", ) + parser.add_argument( + "--redo", + action="store_true", + help="Reprocess file, overwriting existing outputs", + ) args = setup_cli(parser) video_path = Path(args.video_path) if not video_path.exists(): parser.error(f"Video file not found: {video_path}") - # Determine output path and warn if overwriting + # Files must be in segment directories (YYYYMMDD/HHMMSS_LEN/) + segment = get_segment_key(video_path) + if segment is None: + parser.error( + f"Video file must be in a segment directory (HHMMSS_LEN/), " + f"but parent is: {video_path.parent.name}" + ) + + # Determine output path output_path = None - segment = None - suffix = None if not args.frames_only: - # Extract segment and suffix for output naming - try: - segment, suffix = segment_and_suffix(video_path) - except ValueError as exc: - parser.error(str(exc)) - - # Use segment from env (set by sense.py) or use derived value - if not os.getenv("SEGMENT_KEY"): - os.environ["SEGMENT_KEY"] = segment - - segment_dir = video_path.parent / segment - segment_dir.mkdir(exist_ok=True) - # Output JSONL matches input filename pattern (e.g., center_DP-3_screen.jsonl) - output_path = segment_dir / f"{suffix}.jsonl" + # Output JSONL in same directory, same stem (e.g., center_DP-3_screen.jsonl) + output_path = video_path.with_suffix(".jsonl") + + # Skip if already processed (unless redo mode) + if not args.redo and output_path.exists(): + logger.info(f"Already processed: {video_path}") + return + if output_path.exists(): logger.warning(f"Overwriting existing analysis file: {output_path}") @@ -800,22 +782,18 @@ async def async_main(): # Emit completion event if output_path and output_path.exists(): journal_path = Path(os.getenv("JOURNAL_PATH", "")) - # Moved path is in segment: YYYYMMDD/HHMMSS_LEN/suffix.webm - moved_path = ( - video_path.parent / segment / f"{suffix}{video_path.suffix}" - ) try: - rel_input = moved_path.relative_to(journal_path) + rel_input = video_path.relative_to(journal_path) rel_output = output_path.relative_to(journal_path) except ValueError: - rel_input = moved_path + rel_input = video_path rel_output = output_path duration_ms = int((time.time() - start_time) * 1000) - # Extract day from video path (video_path.parent is day dir) - day = video_path.parent.name + # Extract day from video path (grandparent is day dir) + day = video_path.parent.parent.name event_fields = { "input": str(rel_input), diff --git a/observe/linux/observer.py b/observe/linux/observer.py index 465936080..149441ed6 100644 --- a/observe/linux/observer.py +++ b/observe/linux/observer.py @@ -81,9 +81,11 @@ class Observer: # Mode tracking (replaces screencast_running boolean) self.current_mode = MODE_IDLE + # Draft folder for current segment (HHMMSS_draft/) + self.draft_dir: str | None = None + # Multi-file screencast tracking self.current_streams: list[StreamInfo] = [] - self.pending_finalizations: list[tuple[str, str]] | None = None self.last_screencast_sizes: dict[str, int] = {} # Tmux capture tracking @@ -212,25 +214,25 @@ class Observer: time_part = dt.strftime("%H%M%S") return date_part, time_part - def _save_audio_segment( - self, day_dir, time_part: str, duration: int, is_muted: bool - ) -> list[str]: + def _save_audio_segment(self, segment_dir: str, is_muted: bool) -> list[str]: """ - Save accumulated audio buffer to disk. + Save accumulated audio buffer to segment directory. Args: - day_dir: Path to the day directory - time_part: Timestamp string (HHMMSS) - duration: Segment duration in seconds + segment_dir: Path to the segment directory (YYYYMMDD/HHMMSS_LEN/) is_muted: Whether to save as split mono files (muted) or stereo (unmuted) Returns: List of saved filenames (empty if nothing saved) """ + from pathlib import Path + if self.accumulated_audio_buffer.size == 0: logger.warning("No audio buffer to save") return [] + segment_path = Path(segment_dir) + if is_muted: # Split mode: save mic and sys as separate mono files mic_data = self.accumulated_audio_buffer[:, 0] @@ -239,11 +241,11 @@ class Observer: mic_bytes = self.audio_recorder.create_mono_flac_bytes(mic_data) sys_bytes = self.audio_recorder.create_mono_flac_bytes(sys_data) - mic_name = f"{time_part}_{duration}_mic_audio.flac" - sys_name = f"{time_part}_{duration}_sys_audio.flac" + mic_name = "mic_audio.flac" + sys_name = "sys_audio.flac" - mic_path = day_dir / mic_name - sys_path = day_dir / sys_name + mic_path = segment_path / mic_name + sys_path = segment_path / sys_name with open(mic_path, "wb") as f: f.write(mic_bytes) @@ -257,8 +259,8 @@ class Observer: flac_bytes = self.audio_recorder.create_flac_bytes( self.accumulated_audio_buffer ) - audio_name = f"{time_part}_{duration}_audio.flac" - flac_path = day_dir / audio_name + audio_name = "audio.flac" + flac_path = segment_path / audio_name with open(flac_path, "wb") as f: f.write(flac_bytes) @@ -270,20 +272,39 @@ class Observer: """ Handle window boundary rollover. + Closes the current draft folder, renames it to final segment name, + and emits the observing event. + Args: new_mode: The mode for the new segment """ + from pathlib import Path + # Get timestamp parts for this window and calculate duration date_part, time_part = self.get_timestamp_parts(self.start_at) duration = int(time.time() - self.start_at) day_dir = day_path(date_part) - # Save audio if we have enough threshold hits + # Stop screencast first (closes file handles) + stopped_streams: list[StreamInfo] = [] + screen_files: list[str] = [] + + if self.current_mode == MODE_SCREENCAST: + logger.info("Stopping previous screencast") + stopped_streams = await self.screencaster.stop() + self.current_streams = [] + self.last_screencast_sizes = {} + self.stalled_chunks = 0 + + # Collect screen filenames (files are already in draft dir with final names) + screen_files = [stream.filename for stream in stopped_streams] + + # Save audio if we have enough threshold hits (to draft dir) did_save_audio = self.threshold_hits >= MIN_HITS_FOR_SAVE audio_files: list[str] = [] - if did_save_audio: + if did_save_audio and self.draft_dir: audio_files = self._save_audio_segment( - day_dir, time_part, duration, self.segment_is_muted + self.draft_dir, self.segment_is_muted ) if audio_files: logger.info( @@ -298,34 +319,12 @@ class Observer: self.accumulated_audio_buffer = np.array([], dtype=np.float32).reshape(0, 2) self.threshold_hits = 0 - # Handle screencast rollover (if we were in screencast mode) - stopped_streams: list[StreamInfo] = [] - screen_files: list[str] = [] - - if self.current_mode == MODE_SCREENCAST: - logger.info("Stopping previous screencast") - stopped_streams = await self.screencaster.stop() - self.current_streams = [] - self.last_screencast_sizes = {} - self.stalled_chunks = 0 - - # Build finalization list and file names - finalizations = [] - for stream in stopped_streams: - final_name = stream.final_name(time_part, duration) - final_path = str(day_dir / final_name) - finalizations.append((stream.temp_path, final_path)) - screen_files.append(final_name) - - if finalizations: - self.pending_finalizations = finalizations - - # Handle tmux capture save (if we were in tmux mode) + # Handle tmux capture save (to draft dir) tmux_files: list[str] = [] - if self.current_mode == MODE_TMUX and self.tmux_captures: - segment_key = f"{time_part}_{duration}" - segment_dir = day_dir / segment_key - tmux_files = write_captures_jsonl(self.tmux_captures, segment_dir) + if self.current_mode == MODE_TMUX and self.tmux_captures and self.draft_dir: + # write_captures_jsonl expects a Path and creates it if needed + # Draft dir already exists + tmux_files = write_captures_jsonl(self.tmux_captures, Path(self.draft_dir)) # Reset tmux state self.tmux_captures = [] @@ -333,6 +332,32 @@ class Observer: self.tmux_sessions_seen = set() self.tmux_capture.reset_hashes() + # Collect all files saved in this segment + files = audio_files + screen_files + tmux_files + segment_key = f"{time_part}_{duration}" + + # Rename draft folder to final segment name (atomic handoff) + if self.draft_dir and files: + final_segment_dir = str(day_dir / segment_key) + try: + os.rename(self.draft_dir, final_segment_dir) + logger.info( + f"Segment finalized: {self.draft_dir} -> {final_segment_dir}" + ) + except OSError as e: + logger.error(f"Failed to rename draft folder: {e}") + # Files stay in draft folder, won't be processed + files = [] + elif self.draft_dir and not files: + # No files to save, remove empty draft folder + try: + os.rmdir(self.draft_dir) + logger.debug(f"Removed empty draft folder: {self.draft_dir}") + except OSError: + pass # May have other files, ignore + + self.draft_dir = None + # Reset timing for new window self.start_at = time.time() # Wall-clock for filenames self.start_at_mono = time.monotonic() # Monotonic for elapsed @@ -344,59 +369,81 @@ class Observer: old_mode = self.current_mode self.current_mode = new_mode - # Start new capture based on mode + # Start new capture based on mode (creates new draft folder) if new_mode == MODE_SCREENCAST and not self.cached_screen_locked: await self.initialize_screencast() + elif new_mode == MODE_TMUX or new_mode == MODE_IDLE: + # Create draft folder for audio/tmux even without screencast + self._create_draft_folder() # MODE_TMUX doesn't need initialization, captures happen in main loop logger.info(f"Mode transition: {old_mode} -> {new_mode}") # Emit observing event with what we saved this boundary - files = audio_files + screen_files + tmux_files - if files: - segment = f"{time_part}_{duration}" - if self.remote_client: # Remote mode: upload files to remote server - file_paths = [day_dir / f for f in files] + segment_dir = day_dir / segment_key + file_paths = [segment_dir / f for f in files] if self.remote_client.upload_and_cleanup( - date_part, segment, file_paths + date_part, segment_key, file_paths ): - logger.info(f"Segment uploaded: {segment} ({len(files)} files)") + logger.info(f"Segment uploaded: {segment_key} ({len(files)} files)") else: logger.error( - f"Segment upload failed: {segment} - files kept locally" + f"Segment upload failed: {segment_key} - files kept locally" ) elif self.callosum: # Local mode: emit to local Callosum + # Files are now simple names (e.g., "audio.flac" not "143022_300_audio.flac") self.callosum.emit( "observe", "observing", day=date_part, - segment=segment, + segment=segment_key, files=files, host=HOST, platform=PLATFORM, ) - logger.info(f"Segment observing: {segment} ({len(files)} files)") + logger.info(f"Segment observing: {segment_key} ({len(files)} files)") + + def _create_draft_folder(self) -> str: + """ + Create a draft folder for the current segment. + + Returns: + Path to the draft folder (YYYYMMDD/HHMMSS_draft/) + """ + date_part, time_part = self.get_timestamp_parts(self.start_at) + day_dir = day_path(date_part) + + # Create draft folder: YYYYMMDD/HHMMSS_draft/ + draft_name = f"{time_part}_draft" + draft_path = str(day_dir / draft_name) + os.makedirs(draft_path, exist_ok=True) + + self.draft_dir = draft_path + logger.debug(f"Created draft folder: {draft_path}") + return draft_path async def initialize_screencast(self) -> bool: """ Start a new screencast recording. + Creates a draft folder and starts GStreamer recording to it. + Returns: True if screencast started successfully, False otherwise. Raises: RuntimeError: If recording fails to start (caller should exit). """ - date_part, time_part = self.get_timestamp_parts(self.start_at) - day_dir = day_path(date_part) + # Create draft folder for this segment + draft_path = self._create_draft_folder() try: streams = await self.screencaster.start( - str(day_dir), time_part, framerate=1, draw_cursor=True + draft_path, framerate=1, draw_cursor=True ) except RuntimeError as e: logger.error(f"Failed to start screencast: {e}") @@ -407,12 +454,12 @@ class Observer: raise RuntimeError("No streams available") self.current_streams = streams - self.last_screencast_sizes = {s.temp_path: 0 for s in streams} + self.last_screencast_sizes = {s.file_path: 0 for s in streams} self.stalled_chunks = 0 logger.info(f"Started screencast with {len(streams)} stream(s)") for stream in streams: - logger.info(f" {stream.position} ({stream.connector}): {stream.temp_path}") + logger.info(f" {stream.position} ({stream.connector}): {stream.file_path}") return True @@ -451,12 +498,12 @@ class Observer: for stream in self.current_streams: try: rel_file = ( - os.path.relpath(stream.temp_path, journal_path) + os.path.relpath(stream.file_path, journal_path) if journal_path - else stream.temp_path + else stream.file_path ) except ValueError: - rel_file = stream.temp_path + rel_file = stream.file_path streams_info.append( { @@ -528,24 +575,6 @@ class Observer: platform=PLATFORM, ) - def finalize_screencast(self, temp_path: str, final_path: str): - """ - Rename screencast from temp to final path. - - Args: - temp_path: Temporary hidden path (.HHMMSS_position_connector.webm) - final_path: Final destination path (HHMMSS_LEN_position_connector_screen.webm) - """ - if not os.path.exists(temp_path): - logger.warning(f"Screencast file not found: {temp_path}") - return - - try: - os.replace(temp_path, final_path) - logger.info(f"Finalized screencast: {final_path}") - except OSError as e: - logger.error(f"Failed to rename {temp_path} to {final_path}: {e}") - async def main_loop(self): """Run the main observer loop.""" logger.info(f"Starting observer loop (interval={self.interval}s)") @@ -555,7 +584,7 @@ class Observer: self.segment_is_muted = self.cached_is_muted # Sync initial mute state self.current_mode = new_mode - # Start initial capture based on mode + # Start initial capture based on mode (creates draft folder) if new_mode == MODE_SCREENCAST and not self.cached_screen_locked: try: await self.initialize_screencast() @@ -563,6 +592,9 @@ class Observer: # Failed to start screencast, exit self.running = False return + else: + # Create draft folder for audio/tmux even without screencast + self._create_draft_folder() logger.info(f"Initial mode: {self.current_mode}") @@ -570,15 +602,6 @@ class Observer: # Sleep for chunk duration await asyncio.sleep(CHUNK_DURATION) - # Process pending screencast finalizations - if self.pending_finalizations: - for temp_path, final_path in self.pending_finalizations: - if os.path.exists(temp_path): - self.finalize_screencast(temp_path, final_path) - else: - logger.warning(f"Pending screencast not found: {temp_path}") - self.pending_finalizations = None - # Check activity status and determine new mode new_mode = await self.check_activity_status() @@ -588,21 +611,9 @@ class Observer: and not self.screencaster.is_healthy() ): logger.warning("Screencast recording failed, stopping gracefully") - stopped_streams = await self.screencaster.stop() - - # Finalize whatever we have - if stopped_streams: - date_part, time_part = self.get_timestamp_parts(self.start_at) - duration = int(time.time() - self.start_at) - day_dir = day_path(date_part) - - for stream in stopped_streams: - if os.path.exists(stream.temp_path): - final_path = str( - day_dir / stream.final_name(time_part, duration) - ) - self.finalize_screencast(stream.temp_path, final_path) + await self.screencaster.stop() + # Files are already in draft folder, will be finalized at next boundary self.current_streams = [] self.last_screencast_sizes = {} self.stalled_chunks = 0 @@ -667,12 +678,12 @@ class Observer: if self.current_mode == MODE_SCREENCAST and self.current_streams: any_growing = False for stream in self.current_streams: - if os.path.exists(stream.temp_path): - current_size = os.path.getsize(stream.temp_path) - last_size = self.last_screencast_sizes.get(stream.temp_path, 0) + if os.path.exists(stream.file_path): + current_size = os.path.getsize(stream.file_path) + last_size = self.last_screencast_sizes.get(stream.file_path, 0) if current_size > last_size: any_growing = True - self.last_screencast_sizes[stream.temp_path] = current_size + self.last_screencast_sizes[stream.file_path] = current_size self.files_growing = any_growing # Fail-fast: exit if screencast stalled (files not growing) @@ -699,58 +710,75 @@ class Observer: async def shutdown(self): """Clean shutdown of observer.""" + from pathlib import Path + # Get timestamp parts for final save date_part, time_part = self.get_timestamp_parts(self.start_at) duration = int(time.time() - self.start_at) day_dir = day_path(date_part) - # Save final audio if threshold met - if self.threshold_hits >= MIN_HITS_FOR_SAVE: - audio_files = self._save_audio_segment( - day_dir, time_part, duration, self.segment_is_muted - ) - if audio_files: - logger.info(f"Saved final audio: {len(audio_files)} file(s)") - - # Stop screencast if running + # Stop screencast first (closes file handles) + stopped_streams: list[StreamInfo] = [] if self.current_mode == MODE_SCREENCAST: logger.info("Stopping screencast for shutdown") stopped_streams = await self.screencaster.stop() + # Brief delay for files to be flushed + await asyncio.sleep(0.5) - if stopped_streams: - # Brief delay for files to be written - await asyncio.sleep(0.5) - - for stream in stopped_streams: - if os.path.exists(stream.temp_path): - final_path = str( - day_dir / stream.final_name(time_part, duration) - ) - self.finalize_screencast(stream.temp_path, final_path) - else: - logger.warning( - f"Screencast file not found after shutdown: {stream.temp_path}" - ) + # Save final audio if threshold met (to draft dir) + audio_files: list[str] = [] + if self.threshold_hits >= MIN_HITS_FOR_SAVE and self.draft_dir: + audio_files = self._save_audio_segment( + self.draft_dir, self.segment_is_muted + ) + if audio_files: + logger.info(f"Saved final audio: {len(audio_files)} file(s)") - # Save tmux captures if in tmux mode - if self.current_mode == MODE_TMUX and self.tmux_captures: - segment_key = f"{time_part}_{duration}" - segment_dir = day_dir / segment_key - tmux_files = write_captures_jsonl(self.tmux_captures, segment_dir) + # Save tmux captures if in tmux mode (to draft dir) + tmux_files: list[str] = [] + if self.current_mode == MODE_TMUX and self.tmux_captures and self.draft_dir: + tmux_files = write_captures_jsonl(self.tmux_captures, Path(self.draft_dir)) if tmux_files: logger.info(f"Saved final tmux captures: {len(tmux_files)} file(s)") - # Process any remaining pending finalizations - if self.pending_finalizations: - await asyncio.sleep(0.5) - for temp_path, final_path in self.pending_finalizations: - if os.path.exists(temp_path): - self.finalize_screencast(temp_path, final_path) - else: - logger.warning( - f"Pending screencast not found after shutdown: {temp_path}" + # Collect all files and finalize segment + screen_files = [stream.filename for stream in stopped_streams] + files = audio_files + screen_files + tmux_files + segment_key = f"{time_part}_{duration}" + + if self.draft_dir and files: + final_segment_dir = str(day_dir / segment_key) + try: + os.rename(self.draft_dir, final_segment_dir) + logger.info(f"Final segment: {self.draft_dir} -> {final_segment_dir}") + + # Emit final observing event + if self.remote_client: + segment_dir = day_dir / segment_key + file_paths = [segment_dir / f for f in files] + self.remote_client.upload_and_cleanup( + date_part, segment_key, file_paths + ) + elif self.callosum: + self.callosum.emit( + "observe", + "observing", + day=date_part, + segment=segment_key, + files=files, + host=HOST, + platform=PLATFORM, ) - self.pending_finalizations = None + except OSError as e: + logger.error(f"Failed to rename final draft folder: {e}") + elif self.draft_dir: + # No files, remove empty draft folder + try: + os.rmdir(self.draft_dir) + except OSError: + pass + + self.draft_dir = None # Stop audio recorder self.audio_recorder.stop_recording() diff --git a/observe/linux/screencast.py b/observe/linux/screencast.py index bfd26eb93..47ed60569 100644 --- a/observe/linux/screencast.py +++ b/observe/linux/screencast.py @@ -54,11 +54,12 @@ class StreamInfo: y: int width: int height: int - temp_path: str + file_path: str # Final path in segment directory - def final_name(self, time_part: str, duration: int) -> str: - """Generate the final filename for this stream.""" - return f"{time_part}_{duration}_{self.position}_{self.connector}_screen.webm" + @property + def filename(self) -> str: + """Return just the filename for event payloads.""" + return os.path.basename(self.file_path) def _get_restore_token_path() -> Path: @@ -233,17 +234,18 @@ class Screencaster: async def start( self, - base_path: str, - timestamp: str, + output_dir: str, framerate: int = 1, draw_cursor: bool = True, ) -> list[StreamInfo]: """ Start screencast recording for all monitors. + Files are written directly to output_dir with final names (position_connector_screen.webm). + The output_dir is typically a draft segment directory that will be renamed on completion. + Args: - base_path: Directory for output files - timestamp: Timestamp prefix for temp files (HHMMSS format) + output_dir: Directory for output files (e.g., YYYYMMDD/HHMMSS_draft/) framerate: Frames per second (default: 1) draw_cursor: Whether to draw mouse cursor (default: True) @@ -373,10 +375,9 @@ class Screencaster: position = info["position_label"] connector = info["connector"] - # Temp file: .HHMMSS_position_connector.webm - temp_path = os.path.join( - base_path, f".{timestamp}_{position}_{connector}.webm" - ) + # Final file path: position_connector_screen.webm + # Written directly to output_dir (draft segment directory) + file_path = os.path.join(output_dir, f"{position}_{connector}_screen.webm") stream_obj = StreamInfo( node_id=node_id, @@ -386,7 +387,7 @@ class Screencaster: y=info["y"], width=info["width"], height=info["height"], - temp_path=temp_path, + file_path=file_path, ) self.streams.append(stream_obj) @@ -397,11 +398,11 @@ class Screencaster: f"videorate ! video/x-raw,framerate={framerate}/1 ! " f"videoconvert ! vp8enc end-usage=cq cq-level=4 max-quantizer=15 " f"keyframe-max-dist=30 static-threshold=100 ! webmmux ! " - f"filesink location={temp_path}" + f"filesink location={file_path}" ) pipeline_parts.append(branch) - logger.info(f" Stream {node_id}: {position} ({connector}) -> {temp_path}") + logger.info(f" Stream {node_id}: {position} ({connector}) -> {file_path}") pipeline_str = " ".join(pipeline_parts) cmd = ["gst-launch-1.0", "-e"] + pipeline_str.split() @@ -439,7 +440,7 @@ class Screencaster: Stop screencast recording gracefully. Returns: - List of StreamInfo with temp_path for finalization. + List of StreamInfo with file_path for the recorded files. """ streams = self.streams.copy() diff --git a/observe/macos/observer.py b/observe/macos/observer.py index a15dd7f04..b514b17b4 100644 --- a/observe/macos/observer.py +++ b/observe/macos/observer.py @@ -68,9 +68,11 @@ class MacOSObserver: # Multi-display tracking (similar to Linux observer) self.current_displays: list[DisplayInfo] = [] self.current_audio: AudioInfo | None = None - self.pending_finalization: list[tuple[str, str]] | None = None self.last_video_sizes: dict[str, int] = {} + # Draft folder for current segment (HHMMSS_draft/) + self.draft_dir: str | None = None + # Activity status cache (updated each loop) self.cached_is_active = False self.cached_idle_time_ms = 0 @@ -222,42 +224,56 @@ class MacOSObserver: """ Handle window boundary rollover. + Closes the current draft folder, renames files to simple names, + renames folder to final segment name, and emits the observing event. + Args: is_active: Whether system is currently active """ + from pathlib import Path + # Get timestamp parts for this window and calculate duration date_part, time_part = self.get_timestamp_parts(self.start_at) duration = int(time.time() - self.start_at) day_dir = day_path(date_part) + segment_key = f"{time_part}_{duration}" saved_files: list[str] = [] - finalizations: list[tuple[str, str]] = [] if self.capture_running: logger.info("Stopping previous capture") self.screencapture.stop() self.capture_running = False - # Build finalization list for video files + # Rename video files to simple names in draft folder for display in self.current_displays: - if os.path.exists(display.temp_path): - final_name = display.final_name(time_part, duration) - final_path = str(day_dir / final_name) - finalizations.append((display.temp_path, final_path)) - saved_files.append(final_name) - - # Check audio threshold before including in finalization - if self.current_audio and os.path.exists(self.current_audio.temp_path): - if self._check_audio_threshold(self.current_audio.temp_path): - final_name = self.current_audio.final_name(time_part, duration) - final_path = str(day_dir / final_name) - finalizations.append((self.current_audio.temp_path, final_path)) - saved_files.append(final_name) - logger.info(f"Audio passed threshold check, saving: {final_name}") + if os.path.exists(display.file_path): + # Simple name: position_displayID_screen.mov + simple_name = f"{display.position}_{display.display_id}_screen.mov" + simple_path = Path(self.draft_dir) / simple_name + try: + os.rename(display.file_path, simple_path) + saved_files.append(simple_name) + except OSError as e: + logger.error(f"Failed to rename {display.file_path}: {e}") + + # Check audio threshold and rename if passing + if self.current_audio and os.path.exists(self.current_audio.file_path): + if self._check_audio_threshold(self.current_audio.file_path): + simple_name = "audio.m4a" + simple_path = Path(self.draft_dir) / simple_name + try: + os.rename(self.current_audio.file_path, simple_path) + saved_files.append(simple_name) + logger.info( + f"Audio passed threshold check, saving: {simple_name}" + ) + except OSError as e: + logger.error(f"Failed to rename audio: {e}") else: - # Delete the temp audio file + # Delete the audio file try: - os.remove(self.current_audio.temp_path) + os.remove(self.current_audio.file_path) logger.info("Audio below threshold, discarded") except OSError as e: logger.warning(f"Failed to remove audio file: {e}") @@ -268,8 +284,26 @@ class MacOSObserver: self.last_video_sizes = {} self.stalled_chunks = 0 - if finalizations: - self.pending_finalization = finalizations + # Rename draft folder to final segment name (atomic handoff) + if self.draft_dir and saved_files: + final_segment_dir = str(day_dir / segment_key) + try: + os.rename(self.draft_dir, final_segment_dir) + logger.info( + f"Segment finalized: {self.draft_dir} -> {final_segment_dir}" + ) + except OSError as e: + logger.error(f"Failed to rename draft folder: {e}") + saved_files = [] # Don't emit event if rename failed + elif self.draft_dir and not saved_files: + # No files to save, remove empty draft folder + try: + os.rmdir(self.draft_dir) + logger.debug(f"Removed empty draft folder: {self.draft_dir}") + except OSError: + pass # May have other files, ignore + + self.draft_dir = None # Reset timing for new window self.start_at = time.time() @@ -278,39 +312,59 @@ class MacOSObserver: # Update segment mute state self.segment_is_muted = self.cached_is_muted - # Start new capture if active and screen not locked + # Start new capture if active and screen not locked (creates new draft folder) if is_active and not self.cached_screen_locked: self.initialize_capture() # Emit observing event with saved files if saved_files and self.callosum: - segment = f"{time_part}_{duration}" self.callosum.emit( "observe", "observing", day=date_part, - segment=segment, + segment=segment_key, files=saved_files, host=HOST, platform=PLATFORM, ) - logger.info(f"Segment observing: {segment} ({len(saved_files)} files)") + logger.info(f"Segment observing: {segment_key} ({len(saved_files)} files)") + + def _create_draft_folder(self) -> str: + """ + Create a draft folder for the current segment. + + Returns: + Path to the draft folder (YYYYMMDD/HHMMSS_draft/) + """ + date_part, time_part = self.get_timestamp_parts(self.start_at) + day_dir = day_path(date_part) + + # Create draft folder: YYYYMMDD/HHMMSS_draft/ + draft_name = f"{time_part}_draft" + draft_path = str(day_dir / draft_name) + os.makedirs(draft_path, exist_ok=True) + + self.draft_dir = draft_path + logger.debug(f"Created draft folder: {draft_path}") + return draft_path def initialize_capture(self) -> bool: """ Start a new screencast and audio recording. + Creates a draft folder and starts sck-cli recording. + Returns: True if capture started successfully, False otherwise """ - date_part, time_part = self.get_timestamp_parts(self.start_at) - day_dir = day_path(date_part) + from pathlib import Path - # Ensure day directory exists - day_dir.mkdir(parents=True, exist_ok=True) + # Create draft folder for this segment + draft_path = self._create_draft_folder() - # Build temp output base (hidden file) - output_base = day_dir / f".{time_part}" + # Build output base for sck-cli (inside draft folder) + # sck-cli will create files like: draft/capture_1.mov, draft/capture.m4a + output_base = Path(draft_path) / "capture" try: displays, audio = self.screencapture.start( @@ -323,16 +377,16 @@ class MacOSObserver: self.current_displays = displays self.current_audio = audio self.capture_running = True - self.last_video_sizes = {d.temp_path: 0 for d in displays} + self.last_video_sizes = {d.file_path: 0 for d in displays} self.stalled_chunks = 0 logger.info(f"Started capture with {len(displays)} display(s)") for display in displays: logger.info( - f" Display {display.display_id}: {display.position} -> {display.temp_path}" + f" Display {display.display_id}: {display.position} -> {display.file_path}" ) if audio: - logger.info(f" Audio: {audio.temp_path}") + logger.info(f" Audio: {audio.file_path}") return True @@ -361,12 +415,12 @@ class MacOSObserver: for display in self.current_displays: try: rel_file = ( - os.path.relpath(display.temp_path, journal_path) + os.path.relpath(display.file_path, journal_path) if journal_path - else display.temp_path + else display.file_path ) except ValueError: - rel_file = display.temp_path + rel_file = display.file_path streams_info.append( { @@ -415,24 +469,6 @@ class MacOSObserver: platform=PLATFORM, ) - def finalize_screencast(self, temp_path: str, final_path: str): - """ - Rename capture file from temp to final path. - - Args: - temp_path: Temporary file path - final_path: Final destination path - """ - if not os.path.exists(temp_path): - logger.warning(f"Capture file not found: {temp_path}") - return - - try: - os.replace(temp_path, final_path) - logger.info(f"Finalized: {final_path}") - except OSError as e: - logger.error(f"Failed to rename {temp_path} to {final_path}: {e}") - async def main_loop(self): """Run the main observer loop.""" logger.info(f"Starting observer loop (interval={self.interval}s)") @@ -451,15 +487,6 @@ class MacOSObserver: # Sleep for chunk duration await asyncio.sleep(CHUNK_DURATION) - # Process pending finalizations - if self.pending_finalization: - for temp_path, final_path in self.pending_finalization: - if os.path.exists(temp_path): - self.finalize_screencast(temp_path, final_path) - else: - logger.warning(f"Pending file not found: {temp_path}") - self.pending_finalization = None - # Check activity status is_active = self.check_activity_status() @@ -499,12 +526,12 @@ class MacOSObserver: if self.capture_running and self.current_displays: any_growing = False for display in self.current_displays: - if os.path.exists(display.temp_path): - current_size = os.path.getsize(display.temp_path) - last_size = self.last_video_sizes.get(display.temp_path, 0) + if os.path.exists(display.file_path): + current_size = os.path.getsize(display.file_path) + last_size = self.last_video_sizes.get(display.file_path, 0) if current_size > last_size: any_growing = True - self.last_video_sizes[display.temp_path] = current_size + self.last_video_sizes[display.file_path] = current_size self.files_growing = any_growing # Fail-fast: exit if capture stalled (files not growing) @@ -531,6 +558,8 @@ class MacOSObserver: async def shutdown(self): """Clean shutdown of observer.""" + from pathlib import Path + # Stop capture if running if self.capture_running: logger.info("Stopping capture for shutdown") @@ -543,36 +572,67 @@ class MacOSObserver: date_part, time_part = self.get_timestamp_parts(self.start_at) duration = int(time.time() - self.start_at) day_dir = day_path(date_part) + segment_key = f"{time_part}_{duration}" + + saved_files: list[str] = [] - # Finalize video files + # Rename video files to simple names in draft folder for display in self.current_displays: - if os.path.exists(display.temp_path): - final_name = display.final_name(time_part, duration) - final_path = str(day_dir / final_name) - self.finalize_screencast(display.temp_path, final_path) - - # Check and finalize audio if threshold met - if self.current_audio and os.path.exists(self.current_audio.temp_path): - if self._check_audio_threshold(self.current_audio.temp_path): - final_name = self.current_audio.final_name(time_part, duration) - final_path = str(day_dir / final_name) - self.finalize_screencast(self.current_audio.temp_path, final_path) + if os.path.exists(display.file_path): + simple_name = f"{display.position}_{display.display_id}_screen.mov" + simple_path = Path(self.draft_dir) / simple_name + try: + os.rename(display.file_path, simple_path) + saved_files.append(simple_name) + except OSError as e: + logger.error(f"Failed to rename {display.file_path}: {e}") + + # Check and rename audio if threshold met + if self.current_audio and os.path.exists(self.current_audio.file_path): + if self._check_audio_threshold(self.current_audio.file_path): + simple_name = "audio.m4a" + simple_path = Path(self.draft_dir) / simple_name + try: + os.rename(self.current_audio.file_path, simple_path) + saved_files.append(simple_name) + except OSError as e: + logger.error(f"Failed to rename audio: {e}") else: try: - os.remove(self.current_audio.temp_path) + os.remove(self.current_audio.file_path) logger.info("Final audio below threshold, discarded") except OSError: pass - self.capture_running = False + # Rename draft folder to final segment name + if self.draft_dir and saved_files: + final_segment_dir = str(day_dir / segment_key) + try: + os.rename(self.draft_dir, final_segment_dir) + logger.info(f"Segment finalized: {segment_key}") + + # Emit observing event for final segment + if self.callosum: + self.callosum.emit( + "observe", + "observing", + day=date_part, + segment=segment_key, + files=saved_files, + host=HOST, + platform=PLATFORM, + ) + except OSError as e: + logger.error(f"Failed to rename draft folder: {e}") + elif self.draft_dir: + # No files, clean up draft folder + try: + os.rmdir(self.draft_dir) + except OSError: + pass - # Process any remaining pending finalizations - if self.pending_finalization: - await asyncio.sleep(0.5) - for temp_path, final_path in self.pending_finalization: - if os.path.exists(temp_path): - self.finalize_screencast(temp_path, final_path) - self.pending_finalization = None + self.draft_dir = None + self.capture_running = False # Stop Callosum connection if self.callosum: diff --git a/observe/macos/screencapture.py b/observe/macos/screencapture.py index 6b75a1d18..0fb245b09 100644 --- a/observe/macos/screencapture.py +++ b/observe/macos/screencapture.py @@ -16,7 +16,6 @@ import subprocess import threading import time from dataclasses import dataclass -from pathlib import Path from typing import Optional from observe.utils import assign_monitor_positions @@ -37,32 +36,23 @@ class DisplayInfo: y: int width: int height: int - temp_path: str - - def final_name(self, time_part: str, duration: int) -> str: - """Generate the final filename for this display's video.""" - return f"{time_part}_{duration}_{self.position}_{self.display_id}_screen.mov" + file_path: str # Path where sck-cli writes the file @dataclass class AudioInfo: """Information about the audio recording.""" - temp_path: str + file_path: str # Path where sck-cli writes the file tracks: list[str] - def final_name(self, time_part: str, duration: int) -> str: - """Generate the final filename for audio.""" - return f"{time_part}_{duration}_audio.m4a" - class ScreenCaptureKitManager: """ Manages sck-cli subprocess for synchronized video and audio capture. Wraps the sck-cli tool to provide lifecycle management, handles process - monitoring, parses JSONL output for display geometry, and manages output - file finalization. + monitoring, and parses JSONL output for display geometry. """ def __init__(self, sck_cli_path: str = "sck-cli"): @@ -105,8 +95,8 @@ class ScreenCaptureKitManager: Example: >>> manager = ScreenCaptureKitManager() - >>> day_dir = Path("journal/20250101") - >>> output_base = day_dir / ".120000" # Hidden temp file + >>> draft_dir = Path("journal/20250101/120000_draft") + >>> output_base = draft_dir / "capture" >>> displays, audio = manager.start(output_base, duration=300) """ # Build command @@ -218,7 +208,7 @@ class ScreenCaptureKitManager: y=mon["box"][1], width=mon["box"][2] - mon["box"][0], height=mon["box"][3] - mon["box"][1], - temp_path=raw["filename"], + file_path=raw["filename"], ) ) @@ -226,7 +216,7 @@ class ScreenCaptureKitManager: if audio_info: tracks = [t["name"] for t in audio_info.get("tracks", [])] self.audio = AudioInfo( - temp_path=audio_info["filename"], + file_path=audio_info["filename"], tracks=tracks, ) else: @@ -236,10 +226,10 @@ class ScreenCaptureKitManager: for display in self.displays: logger.info( f" Display {display.display_id}: {display.position} " - f"({display.width}x{display.height}) -> {display.temp_path}" + f"({display.width}x{display.height}) -> {display.file_path}" ) if self.audio: - logger.info(f" Audio: {self.audio.temp_path} ({self.audio.tracks})") + logger.info(f" Audio: {self.audio.file_path} ({self.audio.tracks})") # Start background threads to log remaining stdout/stderr in real-time self._output_threads = [ diff --git a/observe/sense.py b/observe/sense.py index e01912d0e..d7c8372de 100644 --- a/observe/sense.py +++ b/observe/sense.py @@ -100,12 +100,11 @@ class FileSensor: if file_path.name.startswith("."): return None - # Ignore files in subdirectories (segments, trash/) - # Expected structure: journal_dir/YYYYMMDD/file.ext (2 parts from journal_dir) - # Reject: journal_dir/YYYYMMDD/HHMMSS_LEN/file.ext (3+ parts from journal_dir) + # Files should be in segment directories: journal_dir/YYYYMMDD/HHMMSS_LEN/file.ext + # Expected structure: 3 parts from journal_dir try: rel_path = file_path.relative_to(self.journal_dir) - if len(rel_path.parts) != 2: + if len(rel_path.parts) != 3: return None except ValueError: # File not under journal directory @@ -128,29 +127,27 @@ class FileSensor: ): """Spawn a handler process for the file. + Files are expected to be in segment directories: YYYYMMDD/HHMMSS_LEN/file.ext + Args: - file_path: Path to the file to process + file_path: Path to the file to process (in segment directory) handler_name: Name of the handler (e.g., "describe", "transcribe") command: Command template with {file} placeholder day: Day string (YYYYMMDD), extracted from path if not provided batch: Whether this is from batch processing mode - segment: Segment key for SEGMENT_KEY env var + segment: Segment key, extracted from path if not provided remote: Remote name for REMOTE_NAME env var """ - # Extract day from path if not provided (journal_dir/YYYYMMDD/file.ext) - if day is None: - try: - rel_path = file_path.relative_to(self.journal_dir) - if len(rel_path.parts) >= 1: + # Extract day and segment from path: journal_dir/YYYYMMDD/HHMMSS_LEN/file.ext + try: + rel_path = file_path.relative_to(self.journal_dir) + if len(rel_path.parts) >= 2: + if day is None: day = rel_path.parts[0] - except ValueError: - pass - - # Extract segment from filename if not provided - if segment is None: - from think.utils import segment_key as get_segment_key - - segment = get_segment_key(file_path.name) + if segment is None: + segment = rel_path.parts[1] + except ValueError: + pass with self.lock: # Skip if already processing this file @@ -185,12 +182,6 @@ class FileSensor: # Generate correlation ID for this handler run ref = str(int(time.time() * 1000)) - # Create segment directory before emitting detected event - # This ensures the directory exists for event logging - if day and segment: - segment_dir = self.journal_dir / day / segment - segment_dir.mkdir(exist_ok=True) - # Emit detected event with file and ref if self.callosum: try: @@ -223,10 +214,8 @@ class FileSensor: # Use unified runner to spawn process with automatic logging logger.info(f"Spawning {handler_name} for {file_path.name}: {' '.join(cmd)}") - # Build environment with segment/remote context for handlers + # Build environment with remote context for handlers env = os.environ.copy() - if segment: - env["SEGMENT_KEY"] = segment if remote: env["REMOTE_NAME"] = remote @@ -306,9 +295,9 @@ class FileSensor: def _check_segment_observed(self, file_path: Path): """Check if all files for this segment have completed processing.""" - from think.utils import segment_key + from observe.utils import get_segment_key - segment = segment_key(file_path.name) + segment = get_segment_key(file_path) if not segment: return @@ -359,7 +348,7 @@ class FileSensor: Args: file_path: Path to the file to process - segment: Optional segment key for SEGMENT_KEY env var + segment: Optional segment key for tracking remote: Optional remote name for REMOTE_NAME env var """ if not file_path.exists(): @@ -396,8 +385,9 @@ class FileSensor: logger.info(f"Received observing event: {day}/{segment} ({len(files)} files)") # Build full paths for all files in this segment - day_dir = self.journal_dir / day - file_paths = [day_dir / filename for filename in files] + # Files are in segment directories: YYYYMMDD/HHMMSS_LEN/filename + segment_dir = self.journal_dir / day / segment + file_paths = [segment_dir / filename for filename in files] # Pre-register segment tracking with complete file list # This ensures segment completion is tracked correctly even if some files @@ -572,23 +562,35 @@ class FileSensor: def process_day(self, day: str, max_jobs: int = 1): """Process all matching unprocessed files from a specific day directory. - Files are considered unprocessed if the source media file has not been - moved to segments (HHMMSS/). This approach handles incomplete - processing gracefully by re-running even if output files exist. + Files are in segment directories (HHMMSS_LEN/). A file is considered + unprocessed if it has no corresponding .jsonl output file. Args: day: Day in YYYYMMDD format max_jobs: Maximum number of concurrent processing jobs """ + from think.utils import segment_key + day_dir = day_path(day) if not day_dir.exists(): logger.error(f"Day directory not found: {day_dir}") return - # Find all matching unprocessed files (not yet moved to segments) + # Find all matching unprocessed files in segment directories to_process = [] - for file_path in day_dir.iterdir(): - if file_path.is_file(): + for segment_dir in day_dir.iterdir(): + if not segment_dir.is_dir() or not segment_key(segment_dir.name): + continue + + for file_path in segment_dir.iterdir(): + if not file_path.is_file(): + continue + + # Check if output JSONL exists (already processed) + output_path = file_path.with_suffix(".jsonl") + if output_path.exists(): + continue + handler_info = self._match_pattern(file_path) if handler_info: handler_name, command = handler_info @@ -650,47 +652,57 @@ class FileSensor: def scan_day(day_dir: Path) -> dict: """Scan a day directory for processed and unprocessed files. + Files are in segment directories (HHMMSS_LEN/). A file is considered + processed if it has a corresponding .jsonl output file. + Args: day_dir: Path to day directory (YYYYMMDD) Returns: Dictionary with: - "processed": List of JSONL output files in segments (HHMMSS_LEN/audio.jsonl, etc) - - "unprocessed": List of unprocessed source media files in day root + - "unprocessed": List of unprocessed source media files in segments - "pending_segments": Count of unique segments with pending files """ - # Find processed output files in segments (HHMMSS_LEN/) from think.utils import segment_key processed = [] + unprocessed = [] + pending_segment_keys = set() + if not day_dir.exists(): return {"processed": [], "unprocessed": [], "pending_segments": 0} for segment in day_dir.iterdir(): - if segment.is_dir() and segment_key(segment.name): - # Check for audio JSONL files (audio.jsonl, mic_audio.jsonl, etc.) - for audio_file in segment.glob("*audio.jsonl"): - processed.append(f"{segment.name}/{audio_file.name}") - # Check for screen JSONL files (screen.jsonl, etc.) - for screen_file in segment.glob("*screen.jsonl"): - processed.append(f"{segment.name}/{screen_file.name}") + if not segment.is_dir() or not segment_key(segment.name): + continue + + # Check each file in the segment + for file_path in segment.iterdir(): + if not file_path.is_file(): + continue + + # JSONL files are outputs + if file_path.suffix == ".jsonl": + processed.append(f"{segment.name}/{file_path.name}") + continue + + # Check if media file has corresponding JSONL (processed) + if ( + file_path.suffix.lower() in VIDEO_EXTENSIONS + or file_path.suffix.lower() + in ( + ".flac", + ".m4a", + ) + ): + output_path = file_path.with_suffix(".jsonl") + if not output_path.exists(): + unprocessed.append(f"{segment.name}/{file_path.name}") + pending_segment_keys.add(segment.name) processed.sort() - - # Find unprocessed source media (still in day root, not yet moved to segments) - # Match by extension only - any descriptive suffix is allowed - unprocessed = [] - unprocessed.extend(sorted(p.name for p in day_dir.glob("*.flac"))) - unprocessed.extend(sorted(p.name for p in day_dir.glob("*.m4a"))) - for ext in VIDEO_EXTENSIONS: - unprocessed.extend(sorted(p.name for p in day_dir.glob(f"*{ext}"))) - - # Count unique segments with pending files - pending_segment_keys = set() - for filename in unprocessed: - key = segment_key(filename) - if key: - pending_segment_keys.add(key) + unprocessed.sort() return { "processed": processed, @@ -722,12 +734,12 @@ def main(): sensor = FileSensor(journal, verbose=args.verbose, debug=args.debug) - # Register handlers - match by extension, ignore descriptive suffix - # Audio files: any HHMMSS_*.flac or HHMMSS_*.m4a in day root + # Register handlers - match by extension + # Audio files in segment directories sensor.register("*.flac", "transcribe", ["observe-transcribe", "{file}"]) sensor.register("*.m4a", "transcribe", ["observe-transcribe", "{file}"]) - # Video files: any HHMMSS_*.webm, HHMMSS_*.mp4, HHMMSS_*.mov in day root + # Video files in segment directories for ext in VIDEO_EXTENSIONS: sensor.register(f"*{ext}", "describe", ["observe-describe", "{file}"]) diff --git a/observe/transcribe.py b/observe/transcribe.py index 6d7018750..d2fc0c4fc 100644 --- a/observe/transcribe.py +++ b/observe/transcribe.py @@ -22,7 +22,7 @@ from google import genai from observe.diarize import DiarizationError, diarize, save_speaker_embeddings from observe.hear import SAMPLE_RATE -from observe.utils import get_segment_key, segment_and_suffix +from observe.utils import get_segment_key from think.callosum import callosum_send from think.entities import load_entity_names from think.models import GEMINI_FLASH @@ -156,29 +156,6 @@ class Transcriber: self.prompt_text = prompt_data.text - def _segment_info(self, audio_path: Path) -> tuple[Path, str, bool]: - """Return segment directory, descriptive suffix, and whether already in segment.""" - segment, suffix = segment_and_suffix(audio_path) - in_segment = get_segment_key(audio_path.parent) is not None - if in_segment: - return audio_path.parent, suffix, True - return audio_path.parent / segment, suffix, False - - def _move_to_segment(self, audio_path: Path) -> Path: - """Move audio file to its segment and return new path.""" - segment_dir, suffix, in_segment = self._segment_info(audio_path) - if in_segment: - return audio_path - try: - segment_dir.mkdir(exist_ok=True) - new_path = segment_dir / f"{suffix}.flac" - audio_path.rename(new_path) - logging.info("Moved %s to %s", audio_path, segment_dir) - return new_path - except Exception as exc: - logging.error("Failed to move %s to segment: %s", audio_path, exc) - return audio_path - def _prepare_audio(self, raw_path: Path) -> Path: """Prepare audio file for diarization, converting if needed. @@ -302,15 +279,10 @@ class Transcriber: data = data.mean(axis=1) # Extract date and time based on path structure + # Files are always in segment directories: YYYYMMDD/HHMMSS_LEN/audio.flac segment = get_segment_key(raw_path) - time_part = ( - segment.split("_")[0] if segment else raw_path.stem.split("_")[0] - ) - # Day dir is parent or grandparent depending on whether file is in segment - if get_segment_key(raw_path.parent) is not None: - day_str = raw_path.parent.parent.name - else: - day_str = raw_path.parent.name + time_part = segment.split("_")[0] if segment else "000000" + day_str = raw_path.parent.parent.name base_dt = datetime.datetime.strptime( f"{day_str}_{time_part}", "%Y%m%d_%H%M%S" @@ -379,26 +351,20 @@ class Transcriber: audio_path.unlink() def _get_json_path(self, audio_path: Path) -> Path: - """Generate the corresponding JSONL path in timestamp directory. + """Generate the corresponding JSONL path in segment directory. - Handles both locations: - - Day root: YYYYMMDD/HHMMSS_LEN_audio.flac -> YYYYMMDD/HHMMSS_LEN/audio.jsonl - - Segment dir: YYYYMMDD/HHMMSS_LEN/audio.flac -> YYYYMMDD/HHMMSS_LEN/audio.jsonl + Files are always in segment directories: + YYYYMMDD/HHMMSS_LEN/audio.flac -> YYYYMMDD/HHMMSS_LEN/audio.jsonl """ - segment_dir, suffix, in_segment = self._segment_info(audio_path) - if not in_segment: - segment_dir.mkdir(exist_ok=True) - return segment_dir / f"{suffix}.jsonl" + return audio_path.with_suffix(".jsonl") def _get_embeddings_dir(self, audio_path: Path) -> Path: """Get directory for storing speaker embeddings. - Handles both locations: - - Day root: YYYYMMDD/HHMMSS_LEN_audio.flac -> YYYYMMDD/HHMMSS_LEN/audio/ - - Segment dir: YYYYMMDD/HHMMSS_LEN/audio.flac -> YYYYMMDD/HHMMSS_LEN/audio/ + Files are always in segment directories: + YYYYMMDD/HHMMSS_LEN/audio.flac -> YYYYMMDD/HHMMSS_LEN/audio/ """ - segment_dir, suffix, _ = self._segment_info(audio_path) - return segment_dir / suffix + return audio_path.parent / audio_path.stem def _transcribe( self, @@ -463,11 +429,8 @@ class Transcriber: transcript_items = result[:-1] # Add audio file reference to metadata - # Day root: stem is HHMMSS_LEN_suffix, need to extract suffix - # Segment dir: stem is already the suffix (e.g., "audio") - _, suffix, _ = self._segment_info(raw_path) - - metadata["raw"] = f"{suffix}.flac" + # Files are in segment directories, stem is the suffix (e.g., "audio") + metadata["raw"] = f"{raw_path.stem}{raw_path.suffix}" # Add remote origin if set (from sense.py for remote observer uploads) remote = os.getenv("REMOTE_NAME") @@ -486,6 +449,7 @@ class Transcriber: # Extract source from _audio pattern # mic_audio -> "mic", sys_audio -> "sys", phone_audio -> "phone", etc. source = None + suffix = raw_path.stem if suffix.endswith("_audio") and suffix != "audio": source = suffix[:-6] # Remove "_audio" suffix @@ -507,23 +471,21 @@ class Transcriber: def _handle_raw(self, raw_path: Path, redo: bool = False) -> None: """Process a raw audio file. + Files are expected to be in segment directories (YYYYMMDD/HHMMSS_LEN/). + Args: - raw_path: Path to audio file - redo: If True, skip "already processed" check and don't move file - (for reprocessing files already in segment directories) + raw_path: Path to audio file in segment directory + redo: If True, skip "already processed" check """ start_time = time.time() - # Use segment from env (set by sense.py) or derive from path - segment = os.getenv("SEGMENT_KEY") or get_segment_key(raw_path) - if segment and not os.getenv("SEGMENT_KEY"): - os.environ["SEGMENT_KEY"] = segment + # Derive segment from path (parent dir is segment dir) + segment = get_segment_key(raw_path) # Skip if already processed (unless redo mode) json_path = self._get_json_path(raw_path) if not redo and json_path.exists(): - logging.info(f"Already processed, moving to timestamp dir: {raw_path}") - self._move_to_segment(raw_path) + logging.info(f"Already processed: {raw_path}") return # Process audio with diarization @@ -545,12 +507,6 @@ class Transcriber: # Transcribe success = self._transcribe(raw_path, turns, speakers, diarization_data) if success: - # In redo mode, file is already in segment dir - don't move - if redo: - final_path = raw_path - else: - final_path = self._move_to_segment(raw_path) - # Save speaker embeddings if speaker_embeddings: embeddings_dir = self._get_embeddings_dir(raw_path) @@ -561,14 +517,14 @@ class Transcriber: duration_ms = int((time.time() - start_time) * 1000) try: - rel_input = final_path.relative_to(journal_path) + rel_input = raw_path.relative_to(journal_path) rel_output = json_path.relative_to(journal_path) except ValueError: - rel_input = final_path + rel_input = raw_path rel_output = json_path - # Extract day from audio path (raw_path.parent is day dir) - day = raw_path.parent.name + # Extract day from audio path (grandparent is day dir) + day = raw_path.parent.parent.name event_fields = { "input": str(rel_input), @@ -592,12 +548,12 @@ def main(): parser.add_argument( "audio_path", type=str, - help="Path to audio file to process (.flac or .m4a)", + help="Path to audio file in segment directory (.flac or .m4a)", ) parser.add_argument( "--redo", action="store_true", - help="Reprocess file already in segment directory, overwriting outputs", + help="Reprocess file, overwriting existing outputs", ) args = setup_cli(parser) @@ -623,13 +579,12 @@ def main(): f"Supported formats: {', '.join(supported_formats)}" ) - # Validate --redo requires file to be in segment directory - if args.redo: - if get_segment_key(audio_path.parent) is None: - parser.error( - f"--redo requires audio file to be in a segment directory (HHMMSS_LEN/), " - f"but parent is: {audio_path.parent.name}" - ) + # Files must be in segment directories (YYYYMMDD/HHMMSS_LEN/) + if get_segment_key(audio_path) is None: + parser.error( + f"Audio file must be in a segment directory (HHMMSS_LEN/), " + f"but parent is: {audio_path.parent.name}" + ) logging.info(f"Processing audio: {audio_path}") diff --git a/observe/utils.py b/observe/utils.py index c1a92f9b9..5fcf610af 100644 --- a/observe/utils.py +++ b/observe/utils.py @@ -14,62 +14,12 @@ VIDEO_EXTENSIONS = (".webm", ".mp4", ".mov") AUDIO_EXTENSIONS = (".flac", ".ogg", ".m4a") -def extract_descriptive_suffix(filename: str) -> str: - """ - Extract descriptive suffix from media filename. - - Returns the portion after the segment (HHMMSS_LEN), preserving - the descriptive information for the final filename in the segment directory. - - Parameters - ---------- - filename : str - Filename stem (without extension), e.g., "143022_300_audio" - - Returns - ------- - str - Descriptive suffix (e.g., "audio", "screen", "mic_sys"), or "raw" if none - - Examples - -------- - >>> extract_descriptive_suffix("143022_300_audio") - "audio" - >>> extract_descriptive_suffix("143022_300_screen") - "screen" - >>> extract_descriptive_suffix("143022_300_mic_sys") - "mic_sys" - >>> extract_descriptive_suffix("143022_300") - "raw" - """ - parts = filename.split("_") - - # Filename format: HHMMSS_LEN[_descriptive_text...] - # First part must be 6-digit timestamp - if not parts or not parts[0].isdigit() or len(parts[0]) != 6: - raise ValueError( - f"Invalid filename format: {filename} (must start with HHMMSS)" - ) - - # Second part must be numeric duration suffix - if len(parts) < 2 or not parts[1].isdigit(): - raise ValueError( - f"Invalid filename format: {filename} (must have HHMMSS_LEN format)" - ) - - # HHMMSS_LEN_suffix... - join remaining parts as descriptive suffix - if len(parts) > 2: - return "_".join(parts[2:]) - else: - return "raw" - - def get_segment_key(media_path: Path) -> str | None: """ Extract segment key from a media file path. - Checks parent directory first (for files already in segment dirs), - then falls back to filename stem (for files in day root). + For the new model, files are always in segment directories (HHMMSS_LEN/). + The segment key is the parent directory name. Parameters ---------- @@ -85,33 +35,26 @@ def get_segment_key(media_path: Path) -> str | None: -------- >>> get_segment_key(Path("/journal/20250101/143022_300/audio.flac")) "143022_300" - >>> get_segment_key(Path("/journal/20250101/143022_300_audio.flac")) - "143022_300" >>> get_segment_key(Path("/journal/20250101/random.txt")) None """ from think.utils import segment_key - # Check if parent directory is a segment (file already moved) - parent_segment = segment_key(media_path.parent.name) - if parent_segment: - return parent_segment - - # Check if filename contains segment (file in day root) - return segment_key(media_path.stem) + # Segment key is the parent directory name + return segment_key(media_path.parent.name) def segment_and_suffix(media_path: Path) -> tuple[str, str]: """ Extract segment key and descriptive suffix from a media file path. - Handles both files in day root (YYYYMMDD/HHMMSS_LEN_suffix.ext) and - files already in segment directories (YYYYMMDD/HHMMSS_LEN/suffix.ext). + For the new model, files are always in segment directories. + The segment key is the parent directory name, suffix is the file stem. Parameters ---------- media_path : Path - Path to media file (audio or video) + Path to media file (audio or video) in a segment directory Returns ------- @@ -121,50 +64,42 @@ def segment_and_suffix(media_path: Path) -> tuple[str, str]: Raises ------ ValueError - If the path doesn't contain a valid segment key + If the parent directory is not a valid segment Examples -------- - >>> segment_and_suffix(Path("/journal/20250101/143022_300_audio.flac")) - ("143022_300", "audio") >>> segment_and_suffix(Path("/journal/20250101/143022_300/audio.flac")) ("143022_300", "audio") + >>> segment_and_suffix(Path("/journal/20250101/143022_300/center_DP-3_screen.webm")) + ("143022_300", "center_DP-3_screen") """ from think.utils import segment_key - # Check if parent directory is a segment (file already moved) - parent_segment = segment_key(media_path.parent.name) - if parent_segment: - # File is in segment dir - stem is the suffix - return parent_segment, media_path.stem - - # File is in day root - extract segment from filename - segment = segment_key(media_path.stem) + # Segment key is the parent directory name + segment = segment_key(media_path.parent.name) if segment is None: raise ValueError( - f"Invalid media filename: {media_path.stem} (must contain HHMMSS_LEN)" + f"File not in segment directory: {media_path} " + f"(parent {media_path.parent.name} is not HHMMSS_LEN format)" ) - suffix = extract_descriptive_suffix(media_path.stem) - return segment, suffix + # Suffix is the file stem + return segment, media_path.stem def parse_screen_filename(filename: str) -> tuple[str, str]: """ Parse position and connector/displayID from a per-monitor screen filename. - Handles both pre-move filenames (with segment prefix) and post-move filenames - (in segment directory without prefix). Works with both GNOME connector IDs - (e.g., "DP-3") and macOS displayIDs (e.g., "1"). + Files are in segment directories with format: position_connector_screen.ext + Works with both GNOME connector IDs (e.g., "DP-3") and macOS displayIDs (e.g., "1"). Parameters ---------- filename : str Filename stem (without extension), e.g.: - - "143022_300_center_DP-3_screen" (GNOME pre-move) - - "143022_300_center_1_screen" (macOS pre-move) - - "center_DP-3_screen" (GNOME post-move) - - "center_1_screen" (macOS post-move) + - "center_DP-3_screen" (GNOME) + - "center_1_screen" (macOS) Returns ------- @@ -174,24 +109,15 @@ def parse_screen_filename(filename: str) -> tuple[str, str]: Examples -------- - >>> parse_screen_filename("143022_300_center_DP-3_screen") - ("center", "DP-3") - >>> parse_screen_filename("143022_300_center_1_screen") - ("center", "1") >>> parse_screen_filename("center_DP-3_screen") ("center", "DP-3") >>> parse_screen_filename("center_1_screen") ("center", "1") - >>> parse_screen_filename("143022_300_screen") - ("unknown", "unknown") + >>> parse_screen_filename("left_HDMI-1_screen") + ("left", "HDMI-1") """ - # Pattern 1: HHMMSS_LEN_position_connector_screen (pre-move) + # Pattern: position_connector_screen # Connector can be alphanumeric with hyphens (GNOME: DP-3) or just numeric (macOS: 1) - match = re.match(r"^\d{6}_\d+_([a-z-]+)_([A-Za-z0-9-]+)_screen$", filename) - if match: - return match.group(1), match.group(2) - - # Pattern 2: position_connector_screen (post-move, in segment directory) match = re.match(r"^([a-z-]+)_([A-Za-z0-9-]+)_screen$", filename) if match: return match.group(1), match.group(2) diff --git a/tests/test_journal_stats.py b/tests/test_journal_stats.py index c1810a3f4..aac840fab 100644 --- a/tests/test_journal_stats.py +++ b/tests/test_journal_stats.py @@ -11,7 +11,7 @@ def test_scan_day(tmp_path, monkeypatch): day = journal / "20240101" day.mkdir() - # Create an audio jsonl file in segment directory + # Create an audio jsonl file in segment directory (already processed) ts_dir = day / "123456_300" ts_dir.mkdir() (ts_dir / "audio.jsonl").write_text( @@ -20,9 +20,11 @@ def test_scan_day(tmp_path, monkeypatch): '{"start": "10:01:00", "text": "world"}\n' ) - # Create unprocessed media files (remain in day root, will be moved to segment on processing) - (day / "123456_300_audio.flac").write_bytes(b"RIFF") - (day / "123456_300_center_DP-1_screen.webm").write_bytes(b"WEBM") + # Create unprocessed media files in a second segment directory (no jsonl output yet) + ts_dir2 = day / "134500_300" + ts_dir2.mkdir() + (ts_dir2 / "audio.flac").write_bytes(b"RIFF") + (ts_dir2 / "center_DP-1_screen.webm").write_bytes(b"WEBM") (day / "entities.md").write_text("") (day / "insights").mkdir() diff --git a/tests/test_observe_utils.py b/tests/test_observe_utils.py index 8f8656698..463447756 100644 --- a/tests/test_observe_utils.py +++ b/tests/test_observe_utils.py @@ -148,52 +148,56 @@ class TestAssignMonitorPositions: class TestParseScreenFilename: - """Test screen filename parsing for per-monitor files.""" + """Test screen filename parsing for per-monitor files. + + Files are now always in segment directories with simple names: + position_connector_screen.webm (e.g., center_DP-3_screen.webm) + """ def test_standard_format(self): """Parse standard per-monitor filename.""" - position, connector = parse_screen_filename("143022_300_center_DP-3_screen") + position, connector = parse_screen_filename("center_DP-3_screen") assert position == "center" assert connector == "DP-3" def test_left_position(self): """Parse left position filename.""" - position, connector = parse_screen_filename("120000_600_left_HDMI-1_screen") + position, connector = parse_screen_filename("left_HDMI-1_screen") assert position == "left" assert connector == "HDMI-1" def test_compound_position(self): """Parse compound position like left-top.""" - position, connector = parse_screen_filename("090000_300_left-top_DP-1_screen") + position, connector = parse_screen_filename("left-top_DP-1_screen") assert position == "left-top" assert connector == "DP-1" + def test_macos_numeric_display_id(self): + """Parse macOS numeric display ID.""" + position, connector = parse_screen_filename("center_1_screen") + assert position == "center" + assert connector == "1" + def test_simple_screen_filename(self): """Simple screen filename without position returns unknown.""" - position, connector = parse_screen_filename("143022_300_screen") + position, connector = parse_screen_filename("screen") assert position == "unknown" assert connector == "unknown" def test_audio_filename(self): """Audio filename returns unknown.""" - position, connector = parse_screen_filename("143022_300_audio") + position, connector = parse_screen_filename("audio") assert position == "unknown" assert connector == "unknown" - def test_post_move_format(self): - """Parse post-move filename (in segment directory, no HHMMSS_LEN prefix).""" - position, connector = parse_screen_filename("center_DP-3_screen") - assert position == "center" - assert connector == "DP-3" - - def test_post_move_left_top(self): - """Parse post-move filename with compound position.""" - position, connector = parse_screen_filename("left-top_HDMI-2_screen") - assert position == "left-top" + def test_right_position(self): + """Parse right position filename.""" + position, connector = parse_screen_filename("right_HDMI-2_screen") + assert position == "right" assert connector == "HDMI-2" - def test_plain_screen(self): - """Plain 'screen' filename returns unknown.""" - position, connector = parse_screen_filename("screen") - assert position == "unknown" - assert connector == "unknown" + def test_compound_left_bottom(self): + """Parse compound left-bottom position.""" + position, connector = parse_screen_filename("left-bottom_DP-2_screen") + assert position == "left-bottom" + assert connector == "DP-2" diff --git a/tests/test_sense.py b/tests/test_sense.py index 763b5574a..0d38ffec0 100644 --- a/tests/test_sense.py +++ b/tests/test_sense.py @@ -115,35 +115,41 @@ def test_file_sensor_register(): def test_file_sensor_match_pattern(): - """Test pattern matching logic.""" + """Test pattern matching logic. + + Files are expected to be in segment directories: journal/YYYYMMDD/HHMMSS_LEN/file.ext + """ with tempfile.TemporaryDirectory() as tmpdir: - # Create journal/day structure + # Create journal/day/segment structure journal_dir = Path(tmpdir) day_dir = journal_dir / "20250101" - day_dir.mkdir() + segment_dir = day_dir / "123456_300" + segment_dir.mkdir(parents=True) sensor = FileSensor(journal_dir) sensor.register("*.webm", "describe", ["echo", "{file}"]) - sensor.register("*_raw.flac", "transcribe", ["cat", "{file}"]) + sensor.register("*.flac", "transcribe", ["cat", "{file}"]) - # Should match - files in day directory - webm_file = day_dir / "test.webm" + # Should match - files in segment directory + webm_file = segment_dir / "center_DP-3_screen.webm" assert sensor._match_pattern(webm_file) is not None assert sensor._match_pattern(webm_file)[0] == "describe" - flac_file = day_dir / "123456_300_raw.flac" + flac_file = segment_dir / "audio.flac" assert sensor._match_pattern(flac_file) is not None assert sensor._match_pattern(flac_file)[0] == "transcribe" # Should not match - wrong extension - txt_file = day_dir / "test.txt" + txt_file = segment_dir / "test.txt" assert sensor._match_pattern(txt_file) is None - # Should not match - in segment - segment_dir = day_dir / "123456_300" - segment_dir.mkdir() - segment_file = segment_dir / "audio.jsonl" - assert sensor._match_pattern(segment_file) is None + # Should not match - file in day root (not in segment dir) + day_root_file = day_dir / "orphan.webm" + assert sensor._match_pattern(day_root_file) is None + + # Should not match - jsonl output file + jsonl_file = segment_dir / "audio.jsonl" + assert sensor._match_pattern(jsonl_file) is None @patch("think.runner._get_journal_path") @@ -261,14 +267,15 @@ def test_file_sensor_spawn_handler_failing_process(tmp_path): def test_file_sensor_handle_file(tmp_path): """Test file handling dispatches to correct handler.""" with patch.object(FileSensor, "_spawn_handler") as mock_spawn: - # Create journal/day structure + # Create journal/day/segment structure day_dir = tmp_path / "20250101" - day_dir.mkdir() + segment_dir = day_dir / "143022_300" + segment_dir.mkdir(parents=True) sensor = FileSensor(tmp_path) sensor.register("*.webm", "describe", ["echo", "{file}"]) - test_file = day_dir / "test.webm" + test_file = segment_dir / "center_DP-3_screen.webm" test_file.write_text("content") sensor._handle_file(test_file) @@ -310,27 +317,28 @@ def test_file_sensor_stop(): def test_file_sensor_handle_callosum_message(tmp_path): """Test handling of observe.observing Callosum events.""" with patch.object(FileSensor, "_handle_file") as mock_handle: - # Create journal/day structure + # Create journal/day/segment structure day_dir = tmp_path / "20250101" - day_dir.mkdir() + segment_dir = day_dir / "143022_300" + segment_dir.mkdir(parents=True) sensor = FileSensor(tmp_path) sensor.register("*.flac", "transcribe", ["echo", "{file}"]) sensor.register("*.webm", "describe", ["echo", "{file}"]) - # Create test files - audio_file = day_dir / "143022_300_audio.flac" + # Create test files with simple names in segment directory + audio_file = segment_dir / "audio.flac" audio_file.write_text("audio content") - video_file = day_dir / "143022_300_screen.webm" + video_file = segment_dir / "center_DP-3_screen.webm" video_file.write_text("video content") - # Simulate observing event + # Simulate observing event with simple filenames message = { "tract": "observe", "event": "observing", "day": "20250101", "segment": "143022_300", - "files": ["143022_300_audio.flac", "143022_300_screen.webm"], + "files": ["audio.flac", "center_DP-3_screen.webm"], } sensor._handle_callosum_message(message) @@ -390,9 +398,10 @@ def test_file_sensor_segment_observed_includes_day(tmp_path, mock_callosum): """Test that observe.observed event includes day field.""" from think.callosum import CallosumConnection - # Create journal/day structure + # Create journal/day/segment structure day_dir = tmp_path / "20250101" - day_dir.mkdir() + segment_dir = day_dir / "143022_300" + segment_dir.mkdir(parents=True) sensor = FileSensor(tmp_path) sensor.register("*.flac", "transcribe", ["echo", "{file}"]) @@ -402,17 +411,17 @@ def test_file_sensor_segment_observed_includes_day(tmp_path, mock_callosum): sensor.callosum = CallosumConnection() sensor.callosum.start(callback=lambda msg: emitted_events.append(msg)) - # Create test file - audio_file = day_dir / "143022_300_audio.flac" + # Create test file with simple name in segment directory + audio_file = segment_dir / "audio.flac" audio_file.write_text("audio content") - # Simulate observing event to set up segment tracking + # Simulate observing event to set up segment tracking (simple filenames) message = { "tract": "observe", "event": "observing", "day": "20250101", "segment": "143022_300", - "files": ["143022_300_audio.flac"], + "files": ["audio.flac"], } sensor._handle_callosum_message(message)