diff --git a/cht/stream/lifecycle.py b/cht/stream/lifecycle.py index c035d35..2a5119b 100644 --- a/cht/stream/lifecycle.py +++ b/cht/stream/lifecycle.py @@ -5,7 +5,7 @@ from threading import Thread from gi.repository import GLib -from cht.config import TRANSCRIBE_MIN_CHUNK_S +from cht.config import TRANSCRIBE_MIN_CHUNK_S, SEGMENT_DURATION from cht.session import rebuild_manifest from cht.stream.manager import StreamManager from cht.stream.tracker import RecordingTracker @@ -191,18 +191,66 @@ class StreamLifecycle: Thread(target=_transcribe, daemon=True, name="transcriber").start() + def _flush_pending_transcript(self): + """Force-transcribe any buffered audio (called before segment rotation).""" + if not self._pending_transcript_audio or not self._stream_mgr: + return + first = self._pending_transcript_audio[0] + first_global = first["global_start"] + first_local = first["local_start"] + seg_path = first["segment_path"] + total_dur = self._pending_transcript_duration + self._pending_transcript_audio.clear() + self._pending_transcript_duration = 0.0 + + mgr = self._stream_mgr + chunk_wav = mgr.audio_dir / f"transcript_{int(first_global):06d}.wav" + + def _transcribe(): + from cht.stream import ffmpeg as ff + try: + ff.extract_audio_chunk( + seg_path, chunk_wav, + start_time=first_local, duration=total_dur, + ) + except Exception as e: + log.error("Flush transcript failed: %s", e) + return + if not chunk_wav.exists(): + return + new_segs = self._transcriber.transcribe_chunk(chunk_wav, time_offset=first_global) + self._transcriber.save_index(mgr.transcript_dir / "index.json") + if new_segs: + GLib.idle_add(self._on_transcript_ready, new_segs) + + Thread(target=_transcribe, daemon=True, name="transcriber_flush").start() + def _check_recorder(self): if not self._streaming or not self._stream_mgr: return False if not self._stream_mgr.recorder_alive(): log.warning("Recorder died — restarting into new segment") - self._stream_mgr.restart_recorder() - self._on_recorder_restarted(self._stream_mgr.recording_path) - # Rebuild manifest with the newly completed segment - try: - rebuild_manifest(self._stream_mgr.session_dir) - except Exception as e: - log.error("Manifest rebuild failed: %s", e) - if self._on_manifest_updated: - GLib.idle_add(self._on_manifest_updated) + self._rotate_segment() + return True + # Proactive rotation: cut segment when it exceeds SEGMENT_DURATION + if SEGMENT_DURATION > 0: + dur = self._stream_mgr._estimate_safe_duration() + if dur and dur >= SEGMENT_DURATION: + log.info("Segment reached %.0fs — rotating", dur) + self._rotate_segment() return True + + def _rotate_segment(self): + """Restart recorder into a new segment and update manifest.""" + # Flush pending transcript buffer from the old segment + if self._pending_transcript_audio and self._pending_transcript_duration > 0: + self._flush_pending_transcript() + + self._stream_mgr.restart_recorder() + self._on_recorder_restarted(self._stream_mgr.recording_path) + try: + rebuild_manifest(self._stream_mgr.session_dir) + except Exception as e: + log.error("Manifest rebuild failed: %s", e) + if self._on_manifest_updated: + GLib.idle_add(self._on_manifest_updated) diff --git a/cht/ui/frames_panel.py b/cht/ui/frames_panel.py index 5deb55c..984ff78 100644 --- a/cht/ui/frames_panel.py +++ b/cht/ui/frames_panel.py @@ -194,6 +194,11 @@ class FramesPanel(Gtk.Box): self._scroll_to(widget) GLib.timeout_add(400, lambda: widget.remove_css_class("frame-highlight") or False) + def scroll_to_end(self): + """Scroll to the last frame.""" + if self._order and self._order[-1] in self._widgets: + self._scroll_to(self._widgets[self._order[-1]]) + def clear(self): """Remove all items and reset state.""" self._selected = None diff --git a/cht/ui/scrub_bar.py b/cht/ui/scrub_bar.py index f64b21f..5440718 100644 --- a/cht/ui/scrub_bar.py +++ b/cht/ui/scrub_bar.py @@ -21,14 +21,9 @@ import cairo log = logging.getLogger(__name__) BAR_HEIGHT = 50 -BLOCK_GAP = 2 -BLOCK_COLOR = (0.25, 0.25, 0.30) -BLOCK_ACTIVE_COLOR = (0.35, 0.45, 0.60) -BLOCK_HOVER_COLOR = (0.30, 0.35, 0.40) -BLOCK_GENERATING_COLOR = (0.30, 0.40, 0.35) +BAR_COLOR = (0.20, 0.20, 0.25) CURSOR_COLOR = (0.9, 0.2, 0.2) MARKER_COLOR = (0.9, 0.8, 0.2) -TEXT_COLOR = (0.8, 0.8, 0.8) class ScrubBar(Gtk.DrawingArea): @@ -170,80 +165,36 @@ class ScrubBar(Gtk.DrawingArea): # -- Drawing -- def _draw(self, area, cr, width, height): - if not self._manifest or self._total_duration <= 0: - cr.set_source_rgb(0.15, 0.15, 0.15) - cr.rectangle(0, 0, width, height) - cr.fill() + # Solid background — always full width + cr.set_source_rgb(*BAR_COLOR) + cr.rectangle(0, 0, width, height) + cr.fill() + + if self._total_duration <= 0: return - # Draw segment blocks - for seg in self._manifest: - x, w = self._segment_rect(seg, width) - idx = seg["index"] - - # Block color based on state - if idx == self._active_index: - cr.set_source_rgb(*BLOCK_ACTIVE_COLOR) - elif idx == self._hover_index: - cr.set_source_rgb(*BLOCK_HOVER_COLOR) - elif self._proxy_states.get(idx) == "generating": - cr.set_source_rgb(*BLOCK_GENERATING_COLOR) - else: - cr.set_source_rgb(*BLOCK_COLOR) - - cr.rectangle(x, 0, w, height) - cr.fill() - - # Segment label - dur = seg["duration"] - m, s = divmod(int(dur), 60) - label = f"S{idx}" if w < 40 else f"S{idx} {m}:{s:02d}" - cr.set_source_rgb(*TEXT_COLOR) - cr.set_font_size(11) - cr.move_to(x + 4, height - 6) - cr.show_text(label) - - # Proxy state indicator - state = self._proxy_states.get(idx) - if state == "ready": - cr.set_source_rgb(0.3, 0.7, 0.3) - cr.arc(x + w - 8, 8, 3, 0, 6.28) - cr.fill() - elif state == "generating": - cr.set_source_rgb(0.7, 0.7, 0.3) - cr.arc(x + w - 8, 8, 3, 0, 6.28) - cr.fill() - # Frame thumbnails at their timestamp positions for thumb in self._frame_thumbs: tx = self._global_to_x(thumb["timestamp"], width) tw, th = thumb["width"], thumb["height"] - # Center thumbnail on its timestamp, vertically centered in bar x0 = tx - tw / 2 y0 = (height - th) / 2 cr.save() - # Dark outline for separation against any background - cr.set_source_rgba(0, 0, 0, 0.7) - cr.set_line_width(2) - cr.rectangle(x0 - 1, y0 - 1, tw + 2, th + 2) + cr.set_source_rgba(0, 0, 0, 0.6) + cr.set_line_width(1.5) + cr.rectangle(x0 - 0.5, y0 - 0.5, tw + 1, th + 1) cr.stroke() - # Thumbnail cr.set_source_surface(thumb["surface"], x0, y0) cr.rectangle(x0, y0, tw, th) cr.fill() - # Bright inner border - cr.set_source_rgba(1, 1, 1, 0.5) - cr.set_line_width(1) - cr.rectangle(x0, y0, tw, th) - cr.stroke() cr.restore() - # Scene markers (on top of thumbs) + # Scene markers cr.set_source_rgb(*MARKER_COLOR) for ts in self._scene_markers: mx = self._global_to_x(ts, width) if 0 <= mx <= width: - cr.rectangle(mx, 0, 1, 6) + cr.rectangle(mx, 0, 1, 5) cr.fill() # Cursor @@ -256,11 +207,11 @@ class ScrubBar(Gtk.DrawingArea): # -- Geometry helpers -- def _segment_rect(self, seg, total_width): - """Return (x, width) for a segment block.""" + """Return (x, width) for a segment's region.""" if self._total_duration <= 0: return 0, 0 - x = (seg["global_offset"] / self._total_duration) * total_width + BLOCK_GAP / 2 - w = (seg["duration"] / self._total_duration) * total_width - BLOCK_GAP + x = (seg["global_offset"] / self._total_duration) * total_width + w = (seg["duration"] / self._total_duration) * total_width return max(0, x), max(1, w) def _global_to_x(self, global_time, total_width): @@ -287,43 +238,36 @@ class ScrubBar(Gtk.DrawingArea): def _on_pressed(self, gesture, n_press, x, y): width = self.get_width() + gt = self._x_to_global(x, width) + # Activate segment if different idx = self._segment_at_x(x, width) - if idx >= 0: - if idx != self._active_index: - # New segment — activate it (proxy will be requested) - self._active_index = idx - self.emit("segment-activated", idx) - else: - # Already active — seek to click position - gt = self._x_to_global(x, width) - self.emit("scrub-position", gt) - self.queue_draw() + if idx >= 0 and idx != self._active_index: + self._active_index = idx + self.emit("segment-activated", idx) + # Always seek to click position + self.emit("scrub-position", gt) + self.queue_draw() def _on_released(self, gesture, n_press, x, y): self._scrubbing = False def _on_motion(self, controller, x, y): - width = self.get_width() - old_hover = self._hover_index - self._hover_index = self._segment_at_x(x, width) - if self._hover_index != old_hover: - self.queue_draw() - - # If scrubbing (dragging within active block), emit position - if self._scrubbing and self._active_index >= 0: + if self._scrubbing: + width = self.get_width() gt = self._x_to_global(x, width) + gt = max(0, min(gt, self._total_duration)) + # Switch segment if drag crosses boundary + idx = self._segment_at_x(x, width) + if idx >= 0 and idx != self._active_index: + self._active_index = idx + self.emit("segment-activated", idx) self.emit("scrub-position", gt) def _on_leave(self, controller): - if self._hover_index != -1: - self._hover_index = -1 - self.queue_draw() + pass def _on_drag_begin(self, gesture, start_x, start_y): - width = self.get_width() - idx = self._segment_at_x(start_x, width) - if idx >= 0 and idx == self._active_index: - self._scrubbing = True + self._scrubbing = True def _on_drag_update(self, gesture, offset_x, offset_y): if self._scrubbing: @@ -333,6 +277,10 @@ class ScrubBar(Gtk.DrawingArea): width = self.get_width() gt = self._x_to_global(x, width) gt = max(0, min(gt, self._total_duration)) + idx = self._segment_at_x(x, width) + if idx >= 0 and idx != self._active_index: + self._active_index = idx + self.emit("segment-activated", idx) self.emit("scrub-position", gt) def _on_drag_end(self, gesture, offset_x, offset_y): diff --git a/cht/ui/transcript_panel.py b/cht/ui/transcript_panel.py index 5f22ae9..e4ae61b 100644 --- a/cht/ui/transcript_panel.py +++ b/cht/ui/transcript_panel.py @@ -173,6 +173,11 @@ class TranscriptPanel(Gtk.Box): self._selected.clear() self.emit("selection-changed") + def scroll_to_end(self): + """Scroll to the bottom (latest transcript).""" + adj = self._scroll.get_vadjustment() + GLib.idle_add(lambda: adj.set_value(adj.get_upper()) or False) + def highlight_nearest(self, timestamp: float) -> None: """Scroll to and briefly highlight the transcript segment closest to *timestamp*.""" if not self._order: diff --git a/cht/window.py b/cht/window.py index 67d0af7..fbd1ebc 100644 --- a/cht/window.py +++ b/cht/window.py @@ -122,10 +122,18 @@ class ChtWindow(Adw.ApplicationWindow): def _on_frame_selection_changed(self, panel): if panel.selected is not None: self._transcript_panel.clear_selection() + ts = panel._timestamps.get(panel.selected) + if ts is not None: + self._timeline_controls.scrub_bar.set_cursor(ts) def _on_transcript_selection_changed(self, panel): if panel.has_selection: self._frames_panel.clear_selection() + last = panel.selected[-1] if panel.selected else None + if last: + ts = panel._timestamps.get(last) + if ts is not None: + self._timeline_controls.scrub_bar.set_cursor(ts) # -- Connect / Disconnect -- @@ -259,11 +267,16 @@ class ChtWindow(Adw.ApplicationWindow): # Refresh manifest so scrub bar shows completed segments self._update_scrub_bar_manifest() else: - # Scrub → Live: restore recording path and resume + # Scrub → Live: restore recording path, refresh GUI, resume mgr = self._lifecycle.stream_mgr if mgr: self._monitor.set_recording(mgr.recording_path) self._timeline.toggle_live() + # Catch up on anything that arrived while scrubbing + self._update_scrub_bar_manifest() + # Scroll panels to latest items + self._frames_panel.scroll_to_end() + self._transcript_panel.scroll_to_end() # -- Scrub -- @@ -282,7 +295,7 @@ class ChtWindow(Adw.ApplicationWindow): scrub_bar.set_frames([{"timestamp": f["timestamp"], "path": str(f["path"])} for f in frames]) def _on_segment_activated(self, scrub_bar, segment_index): - """User clicked a segment block — request its proxy.""" + """User clicked/dragged into a segment — request its proxy.""" if not self._manifest or segment_index >= len(self._manifest): return seg = self._manifest[segment_index] @@ -294,20 +307,14 @@ class ChtWindow(Adw.ApplicationWindow): self._proxy_mgr = ProxyManager(sid) scrub_bar.set_proxy_state(segment_index, "generating") - # Store pending seek position (the click position) - self._pending_scrub_global = seg["global_offset"] def _on_ready(proxy_path): scrub_bar.set_proxy_state(segment_index, "ready") scrub_bar.set_active_segment(segment_index) self._monitor.set_scrub_source(proxy_path, global_offset=seg["global_offset"]) - # Apply pending seek now that proxy is loaded - gt = self._pending_scrub_global + # Seek to current cursor position (set by scrub-position signal) + gt = self._timeline.state.cursor local = gt - seg["global_offset"] - self._timeline.state.cursor = gt - self._timeline.state.live = False - self._timeline.state.paused = True - self._timeline.emit("changed") self._monitor.scrub_to(max(0.0, local)) self._proxy_mgr.request(seg_path, on_ready=_on_ready)