most solid commit
This commit is contained in:
@@ -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, SEGMENT_DURATION
|
from cht.config import TRANSCRIBE_MIN_CHUNK_S
|
||||||
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
|
||||||
@@ -122,6 +122,8 @@ class StreamLifecycle:
|
|||||||
GLib.idle_add(self._go_live_once)
|
GLib.idle_add(self._go_live_once)
|
||||||
if self._stream_mgr:
|
if self._stream_mgr:
|
||||||
self._stream_mgr.capture_now(on_new_frames=self._handle_new_scene_frames)
|
self._stream_mgr.capture_now(on_new_frames=self._handle_new_scene_frames)
|
||||||
|
if self._stream_mgr:
|
||||||
|
self._stream_mgr.capture_now(on_new_frames=self._handle_new_scene_frames)
|
||||||
|
|
||||||
def _go_live_once(self):
|
def _go_live_once(self):
|
||||||
if self._stream_mgr:
|
if self._stream_mgr:
|
||||||
@@ -231,13 +233,6 @@ class StreamLifecycle:
|
|||||||
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()
|
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
|
return True
|
||||||
|
|
||||||
def _rotate_segment(self):
|
def _rotate_segment(self):
|
||||||
|
|||||||
@@ -67,41 +67,36 @@ class WaveformWidget(Gtk.Box):
|
|||||||
cr.line_to(width, mid_y)
|
cr.line_to(width, mid_y)
|
||||||
cr.stroke()
|
cr.stroke()
|
||||||
|
|
||||||
# Draw peaks
|
# Draw peaks — always stretch to fill full width
|
||||||
if self._peaks is not None and len(self._peaks) > 0 and duration > 0:
|
if self._peaks is not None and len(self._peaks) > 0:
|
||||||
n_peaks = len(self._peaks)
|
n_peaks = len(self._peaks)
|
||||||
# Map peaks to pixel columns
|
|
||||||
peak_duration = n_peaks * self._bucket_duration
|
|
||||||
max_peak = np.max(self._peaks) if np.max(self._peaks) > 0 else 1.0
|
max_peak = np.max(self._peaks) if np.max(self._peaks) > 0 else 1.0
|
||||||
|
|
||||||
for x in range(width):
|
for x in range(width):
|
||||||
# Time at this pixel
|
idx = int((x / width) * n_peaks)
|
||||||
t = (x / width) * duration
|
|
||||||
# Corresponding peak index
|
|
||||||
idx = int(t / self._bucket_duration)
|
|
||||||
if 0 <= idx < n_peaks:
|
if 0 <= idx < n_peaks:
|
||||||
val = self._peaks[idx] / max_peak
|
val = self._peaks[idx] / max_peak
|
||||||
bar_h = val * (height * 0.45) # 90% of half-height
|
bar_h = val * (height * 0.45)
|
||||||
# Green gradient based on amplitude
|
|
||||||
cr.set_source_rgba(0.2, 0.6 + val * 0.4, 0.3, 0.85)
|
cr.set_source_rgba(0.2, 0.6 + val * 0.4, 0.3, 0.85)
|
||||||
cr.rectangle(x, mid_y - bar_h, 1, bar_h * 2)
|
cr.rectangle(x, mid_y - bar_h, 1, bar_h * 2)
|
||||||
cr.fill()
|
cr.fill()
|
||||||
|
|
||||||
# Scene markers
|
# Scene markers and playhead use timeline duration
|
||||||
if duration > 0:
|
x_duration = max(duration, 0.1)
|
||||||
cr.set_source_rgba(1.0, 1.0, 0.3, 0.3)
|
|
||||||
cr.set_line_width(1)
|
cr.set_source_rgba(1.0, 1.0, 0.3, 0.3)
|
||||||
for marker in state.scene_markers:
|
cr.set_line_width(1)
|
||||||
mx = (marker / duration) * width
|
for marker in state.scene_markers:
|
||||||
|
mx = (marker / x_duration) * width
|
||||||
|
if 0 <= mx <= width:
|
||||||
cr.move_to(mx, 0)
|
cr.move_to(mx, 0)
|
||||||
cr.line_to(mx, height)
|
cr.line_to(mx, height)
|
||||||
cr.stroke()
|
cr.stroke()
|
||||||
|
|
||||||
# Playhead
|
# Playhead
|
||||||
if duration > 0:
|
px = (state.cursor / x_duration) * width
|
||||||
px = (state.cursor / duration) * width
|
cr.set_source_rgba(1.0, 0.3, 0.3, 0.9)
|
||||||
cr.set_source_rgba(1.0, 0.3, 0.3, 0.9)
|
cr.set_line_width(2)
|
||||||
cr.set_line_width(2)
|
cr.move_to(px, 0)
|
||||||
cr.move_to(px, 0)
|
cr.line_to(px, height)
|
||||||
cr.line_to(px, height)
|
cr.stroke()
|
||||||
cr.stroke()
|
|
||||||
|
|||||||
@@ -237,6 +237,28 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
self._update_scrub_bar_manifest()
|
self._update_scrub_bar_manifest()
|
||||||
self._populate_model_dropdown()
|
self._populate_model_dropdown()
|
||||||
|
|
||||||
|
def _reload_waveform(self, mgr):
|
||||||
|
"""Recompute waveform from existing segments in background."""
|
||||||
|
segments = mgr.recording_segments
|
||||||
|
if not segments:
|
||||||
|
return
|
||||||
|
from cht.stream import ffmpeg as ff
|
||||||
|
|
||||||
|
def _compute():
|
||||||
|
audio_dir = mgr.audio_dir
|
||||||
|
audio_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
full_wav = audio_dir / "full.wav"
|
||||||
|
try:
|
||||||
|
ff.extract_audio_chunk(segments[0], full_wav)
|
||||||
|
self._waveform_engine.compute_full(full_wav)
|
||||||
|
peaks = self._waveform_engine.peaks
|
||||||
|
bucket_dur = self._waveform_engine.bucket_duration
|
||||||
|
GLib.idle_add(self._waveform_widget.set_peaks, peaks.copy(), bucket_dur)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Waveform reload failed: %s", e)
|
||||||
|
|
||||||
|
Thread(target=_compute, daemon=True, name="waveform_reload").start()
|
||||||
|
|
||||||
# -- Streaming --
|
# -- Streaming --
|
||||||
|
|
||||||
def _start_stream(self, session_id=None):
|
def _start_stream(self, session_id=None):
|
||||||
@@ -256,6 +278,7 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
if session_id:
|
if session_id:
|
||||||
self._load_existing_frames()
|
self._load_existing_frames()
|
||||||
self._load_existing_transcript()
|
self._load_existing_transcript()
|
||||||
|
self._reload_waveform(mgr)
|
||||||
|
|
||||||
self.set_title(f"{APP_NAME} — {mgr.session_id}")
|
self.set_title(f"{APP_NAME} — {mgr.session_id}")
|
||||||
log.info("Waiting for sender...")
|
log.info("Waiting for sender...")
|
||||||
@@ -406,6 +429,14 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
|
|
||||||
Thread(target=_capture, daemon=True, name="scrub_capture").start()
|
Thread(target=_capture, daemon=True, name="scrub_capture").start()
|
||||||
|
|
||||||
|
def _manual_segment_cut(self):
|
||||||
|
"""Ctrl+R: manually cut recording into a new segment."""
|
||||||
|
if not self._lifecycle.is_streaming:
|
||||||
|
return
|
||||||
|
log.info("Manual segment cut requested")
|
||||||
|
self._lifecycle._rotate_segment()
|
||||||
|
self._agent_output.append("Segment cut.\n")
|
||||||
|
|
||||||
def _stop_stream(self, reload_session=False):
|
def _stop_stream(self, reload_session=False):
|
||||||
log.info("Stopping stream...")
|
log.info("Stopping stream...")
|
||||||
mgr = self._lifecycle.stream_mgr
|
mgr = self._lifecycle.stream_mgr
|
||||||
@@ -523,6 +554,14 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
kb.bind(KEY_DELETE, lambda **_: self._agent_output.clear())
|
kb.bind(KEY_DELETE, lambda **_: self._agent_output.clear())
|
||||||
kb.attach(self)
|
kb.attach(self)
|
||||||
|
|
||||||
|
# Ctrl+R: manual segment cut
|
||||||
|
ctrl_r = Gtk.ShortcutController()
|
||||||
|
ctrl_r.add_shortcut(Gtk.Shortcut(
|
||||||
|
trigger=Gtk.ShortcutTrigger.parse_string("<Ctrl>r"),
|
||||||
|
action=Gtk.CallbackAction.new(lambda *_: self._manual_segment_cut()),
|
||||||
|
))
|
||||||
|
self.add_controller(ctrl_r)
|
||||||
|
|
||||||
# -- Agent actions --
|
# -- Agent actions --
|
||||||
|
|
||||||
def _build_selection_message(self, verb: str) -> str | None:
|
def _build_selection_message(self, verb: str) -> str | None:
|
||||||
@@ -635,7 +674,7 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
self._known_frames.add(fid)
|
self._known_frames.add(fid)
|
||||||
try:
|
try:
|
||||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(entry["path"]), 256, 144, True)
|
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(entry["path"]), 256, 144, True)
|
||||||
auto = not self._transcript_panel.has_selection
|
auto = self._timeline.state.live and not self._transcript_panel.has_selection
|
||||||
self._frames_panel.add_item(fid, pixbuf, entry["timestamp"], auto_select=auto)
|
self._frames_panel.add_item(fid, pixbuf, entry["timestamp"], auto_select=auto)
|
||||||
self._timeline_controls.scrub_bar.add_frame(entry["timestamp"], str(entry["path"]))
|
self._timeline_controls.scrub_bar.add_frame(entry["timestamp"], str(entry["path"]))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Reference in New Issue
Block a user