not sure about this commit

This commit is contained in:
2026-04-03 08:21:07 -03:00
parent 51c0bdd2da
commit 908231f98a
5 changed files with 122 additions and 109 deletions

View File

@@ -5,7 +5,7 @@ from threading import Thread
from gi.repository import GLib 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.session import rebuild_manifest
from cht.stream.manager import StreamManager from cht.stream.manager import StreamManager
from cht.stream.tracker import RecordingTracker from cht.stream.tracker import RecordingTracker
@@ -191,18 +191,66 @@ class StreamLifecycle:
Thread(target=_transcribe, daemon=True, name="transcriber").start() 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): def _check_recorder(self):
if not self._streaming or not self._stream_mgr: if not self._streaming or not self._stream_mgr:
return False return False
if not self._stream_mgr.recorder_alive(): if not self._stream_mgr.recorder_alive():
log.warning("Recorder died — restarting into new segment") log.warning("Recorder died — restarting into new segment")
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._stream_mgr.restart_recorder()
self._on_recorder_restarted(self._stream_mgr.recording_path) self._on_recorder_restarted(self._stream_mgr.recording_path)
# Rebuild manifest with the newly completed segment
try: try:
rebuild_manifest(self._stream_mgr.session_dir) rebuild_manifest(self._stream_mgr.session_dir)
except Exception as e: except Exception as e:
log.error("Manifest rebuild failed: %s", e) log.error("Manifest rebuild failed: %s", e)
if self._on_manifest_updated: if self._on_manifest_updated:
GLib.idle_add(self._on_manifest_updated) GLib.idle_add(self._on_manifest_updated)
return True

View File

@@ -194,6 +194,11 @@ class FramesPanel(Gtk.Box):
self._scroll_to(widget) self._scroll_to(widget)
GLib.timeout_add(400, lambda: widget.remove_css_class("frame-highlight") or False) 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): def clear(self):
"""Remove all items and reset state.""" """Remove all items and reset state."""
self._selected = None self._selected = None

View File

@@ -21,14 +21,9 @@ import cairo
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
BAR_HEIGHT = 50 BAR_HEIGHT = 50
BLOCK_GAP = 2 BAR_COLOR = (0.20, 0.20, 0.25)
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)
CURSOR_COLOR = (0.9, 0.2, 0.2) CURSOR_COLOR = (0.9, 0.2, 0.2)
MARKER_COLOR = (0.9, 0.8, 0.2) MARKER_COLOR = (0.9, 0.8, 0.2)
TEXT_COLOR = (0.8, 0.8, 0.8)
class ScrubBar(Gtk.DrawingArea): class ScrubBar(Gtk.DrawingArea):
@@ -170,80 +165,36 @@ class ScrubBar(Gtk.DrawingArea):
# -- Drawing -- # -- Drawing --
def _draw(self, area, cr, width, height): def _draw(self, area, cr, width, height):
if not self._manifest or self._total_duration <= 0: # Solid background — always full width
cr.set_source_rgb(0.15, 0.15, 0.15) cr.set_source_rgb(*BAR_COLOR)
cr.rectangle(0, 0, width, height) cr.rectangle(0, 0, width, height)
cr.fill() cr.fill()
if self._total_duration <= 0:
return 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 # Frame thumbnails at their timestamp positions
for thumb in self._frame_thumbs: for thumb in self._frame_thumbs:
tx = self._global_to_x(thumb["timestamp"], width) tx = self._global_to_x(thumb["timestamp"], width)
tw, th = thumb["width"], thumb["height"] tw, th = thumb["width"], thumb["height"]
# Center thumbnail on its timestamp, vertically centered in bar
x0 = tx - tw / 2 x0 = tx - tw / 2
y0 = (height - th) / 2 y0 = (height - th) / 2
cr.save() cr.save()
# Dark outline for separation against any background cr.set_source_rgba(0, 0, 0, 0.6)
cr.set_source_rgba(0, 0, 0, 0.7) cr.set_line_width(1.5)
cr.set_line_width(2) cr.rectangle(x0 - 0.5, y0 - 0.5, tw + 1, th + 1)
cr.rectangle(x0 - 1, y0 - 1, tw + 2, th + 2)
cr.stroke() cr.stroke()
# Thumbnail
cr.set_source_surface(thumb["surface"], x0, y0) cr.set_source_surface(thumb["surface"], x0, y0)
cr.rectangle(x0, y0, tw, th) cr.rectangle(x0, y0, tw, th)
cr.fill() 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() cr.restore()
# Scene markers (on top of thumbs) # Scene markers
cr.set_source_rgb(*MARKER_COLOR) cr.set_source_rgb(*MARKER_COLOR)
for ts in self._scene_markers: for ts in self._scene_markers:
mx = self._global_to_x(ts, width) mx = self._global_to_x(ts, width)
if 0 <= mx <= width: if 0 <= mx <= width:
cr.rectangle(mx, 0, 1, 6) cr.rectangle(mx, 0, 1, 5)
cr.fill() cr.fill()
# Cursor # Cursor
@@ -256,11 +207,11 @@ class ScrubBar(Gtk.DrawingArea):
# -- Geometry helpers -- # -- Geometry helpers --
def _segment_rect(self, seg, total_width): 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: if self._total_duration <= 0:
return 0, 0 return 0, 0
x = (seg["global_offset"] / self._total_duration) * total_width + BLOCK_GAP / 2 x = (seg["global_offset"] / self._total_duration) * total_width
w = (seg["duration"] / self._total_duration) * total_width - BLOCK_GAP w = (seg["duration"] / self._total_duration) * total_width
return max(0, x), max(1, w) return max(0, x), max(1, w)
def _global_to_x(self, global_time, total_width): def _global_to_x(self, global_time, total_width):
@@ -287,15 +238,13 @@ class ScrubBar(Gtk.DrawingArea):
def _on_pressed(self, gesture, n_press, x, y): def _on_pressed(self, gesture, n_press, x, y):
width = self.get_width() width = self.get_width()
gt = self._x_to_global(x, width)
# Activate segment if different
idx = self._segment_at_x(x, width) idx = self._segment_at_x(x, width)
if idx >= 0: if idx >= 0 and idx != self._active_index:
if idx != self._active_index:
# New segment — activate it (proxy will be requested)
self._active_index = idx self._active_index = idx
self.emit("segment-activated", idx) self.emit("segment-activated", idx)
else: # Always seek to click position
# Already active — seek to click position
gt = self._x_to_global(x, width)
self.emit("scrub-position", gt) self.emit("scrub-position", gt)
self.queue_draw() self.queue_draw()
@@ -303,26 +252,21 @@ class ScrubBar(Gtk.DrawingArea):
self._scrubbing = False self._scrubbing = False
def _on_motion(self, controller, x, y): def _on_motion(self, controller, x, y):
if self._scrubbing:
width = self.get_width() 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:
gt = self._x_to_global(x, 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) self.emit("scrub-position", gt)
def _on_leave(self, controller): def _on_leave(self, controller):
if self._hover_index != -1: pass
self._hover_index = -1
self.queue_draw()
def _on_drag_begin(self, gesture, start_x, start_y): 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): def _on_drag_update(self, gesture, offset_x, offset_y):
@@ -333,6 +277,10 @@ class ScrubBar(Gtk.DrawingArea):
width = self.get_width() width = self.get_width()
gt = self._x_to_global(x, width) gt = self._x_to_global(x, width)
gt = max(0, min(gt, self._total_duration)) 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) self.emit("scrub-position", gt)
def _on_drag_end(self, gesture, offset_x, offset_y): def _on_drag_end(self, gesture, offset_x, offset_y):

View File

@@ -173,6 +173,11 @@ class TranscriptPanel(Gtk.Box):
self._selected.clear() self._selected.clear()
self.emit("selection-changed") 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: def highlight_nearest(self, timestamp: float) -> None:
"""Scroll to and briefly highlight the transcript segment closest to *timestamp*.""" """Scroll to and briefly highlight the transcript segment closest to *timestamp*."""
if not self._order: if not self._order:

View File

@@ -122,10 +122,18 @@ class ChtWindow(Adw.ApplicationWindow):
def _on_frame_selection_changed(self, panel): def _on_frame_selection_changed(self, panel):
if panel.selected is not None: if panel.selected is not None:
self._transcript_panel.clear_selection() 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): def _on_transcript_selection_changed(self, panel):
if panel.has_selection: if panel.has_selection:
self._frames_panel.clear_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 -- # -- Connect / Disconnect --
@@ -259,11 +267,16 @@ class ChtWindow(Adw.ApplicationWindow):
# Refresh manifest so scrub bar shows completed segments # Refresh manifest so scrub bar shows completed segments
self._update_scrub_bar_manifest() self._update_scrub_bar_manifest()
else: else:
# Scrub → Live: restore recording path and resume # Scrub → Live: restore recording path, refresh GUI, resume
mgr = self._lifecycle.stream_mgr mgr = self._lifecycle.stream_mgr
if mgr: if mgr:
self._monitor.set_recording(mgr.recording_path) self._monitor.set_recording(mgr.recording_path)
self._timeline.toggle_live() 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 -- # -- Scrub --
@@ -282,7 +295,7 @@ class ChtWindow(Adw.ApplicationWindow):
scrub_bar.set_frames([{"timestamp": f["timestamp"], "path": str(f["path"])} for f in frames]) scrub_bar.set_frames([{"timestamp": f["timestamp"], "path": str(f["path"])} for f in frames])
def _on_segment_activated(self, scrub_bar, segment_index): 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): if not self._manifest or segment_index >= len(self._manifest):
return return
seg = self._manifest[segment_index] seg = self._manifest[segment_index]
@@ -294,20 +307,14 @@ class ChtWindow(Adw.ApplicationWindow):
self._proxy_mgr = ProxyManager(sid) self._proxy_mgr = ProxyManager(sid)
scrub_bar.set_proxy_state(segment_index, "generating") 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): def _on_ready(proxy_path):
scrub_bar.set_proxy_state(segment_index, "ready") scrub_bar.set_proxy_state(segment_index, "ready")
scrub_bar.set_active_segment(segment_index) scrub_bar.set_active_segment(segment_index)
self._monitor.set_scrub_source(proxy_path, global_offset=seg["global_offset"]) self._monitor.set_scrub_source(proxy_path, global_offset=seg["global_offset"])
# Apply pending seek now that proxy is loaded # Seek to current cursor position (set by scrub-position signal)
gt = self._pending_scrub_global gt = self._timeline.state.cursor
local = gt - seg["global_offset"] 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._monitor.scrub_to(max(0.0, local))
self._proxy_mgr.request(seg_path, on_ready=_on_ready) self._proxy_mgr.request(seg_path, on_ready=_on_ready)