not sure about this commit
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user