diff --git a/cht/session.py b/cht/session.py new file mode 100644 index 0000000..a4862b8 --- /dev/null +++ b/cht/session.py @@ -0,0 +1,35 @@ +"""Session data loading — reads frame/transcript indexes, returns plain data.""" + +import json +import logging +from pathlib import Path + +log = logging.getLogger(__name__) + + +def load_frame_index(frames_dir: Path) -> list[dict]: + """Read frames/index.json and return list of {id, path, timestamp}. + + Returns only entries where the image file exists on disk. + Paths are resolved relative to *frames_dir* if needed. + """ + index_path = frames_dir / "index.json" + if not index_path.exists(): + return [] + try: + index = json.loads(index_path.read_text()) + except (json.JSONDecodeError, IOError): + return [] + result = [] + for entry in index: + fpath = Path(entry["path"]) + if not fpath.exists(): + fpath = frames_dir / fpath.name + if not fpath.exists(): + continue + result.append({ + "id": entry["id"], + "path": fpath, + "timestamp": entry.get("timestamp", 0), + }) + return result diff --git a/cht/stream/lifecycle.py b/cht/stream/lifecycle.py new file mode 100644 index 0000000..9fe9f5a --- /dev/null +++ b/cht/stream/lifecycle.py @@ -0,0 +1,179 @@ +"""Stream lifecycle — manages recording, scene detection, audio extraction, and transcription buffering.""" + +import logging +from threading import Thread + +from gi.repository import GLib + +from cht.config import TRANSCRIBE_MIN_CHUNK_S +from cht.stream.manager import StreamManager +from cht.stream.tracker import RecordingTracker + +log = logging.getLogger(__name__) + + +class StreamLifecycle: + """Owns the streaming process state and coordinates background tasks. + + The window provides UI-facing callbacks; this class handles the + process-management side (recorder, tracker, scene detection, audio + extraction, transcription buffering). + """ + + def __init__(self, *, timeline, waveform_engine, transcriber, + on_new_frames, on_waveform_update, on_transcript_ready, + on_scene_marker, on_recorder_restarted): + self._timeline = timeline + self._waveform_engine = waveform_engine + self._transcriber = transcriber + + # Callbacks + self._on_new_frames = on_new_frames + self._on_waveform_update = on_waveform_update + self._on_transcript_ready = on_transcript_ready + self._on_scene_marker = on_scene_marker + self._on_recorder_restarted = on_recorder_restarted + + # State + self._streaming = False + self._gone_live = False + self._stream_mgr: StreamManager | None = None + self._tracker: RecordingTracker | None = None + self._pending_transcript_audio: list[tuple] = [] + self._pending_transcript_duration = 0.0 + + @property + def is_streaming(self) -> bool: + return self._streaming + + @property + def stream_mgr(self) -> StreamManager | None: + return self._stream_mgr + + @property + def tracker(self) -> RecordingTracker | None: + return self._tracker + + def start(self, session_id=None) -> StreamManager: + """Start recording and all background processes. Returns the StreamManager.""" + self._streaming = True + self._gone_live = False + + self._stream_mgr = StreamManager(session_id=session_id) + self._stream_mgr.setup_dirs() + self._stream_mgr.start_recorder() + + self._tracker = RecordingTracker( + get_segments=lambda: self._stream_mgr.recording_segments if self._stream_mgr else [], + on_duration_update=self._on_duration_update, + ) + self._tracker.start() + + self._stream_mgr.start_scene_detector(on_new_frames=self._handle_new_scene_frames) + self._stream_mgr.start_audio_extractor(on_new_audio=self._handle_new_audio) + + GLib.timeout_add(1000, self._tick_live) + GLib.timeout_add(2000, self._check_recorder) + + return self._stream_mgr + + def stop(self): + """Stop all processes and reset state. Does NOT touch UI — caller handles that.""" + if self._tracker: + self._tracker.stop() + self._tracker = None + + readonly = self._stream_mgr.readonly if self._stream_mgr else True + if self._stream_mgr: + if not readonly: + self._stream_mgr.stop_all() + self._stream_mgr = None + + self._streaming = False + self._gone_live = False + self._pending_transcript_audio.clear() + self._pending_transcript_duration = 0.0 + + def set_manager_readonly(self, mgr: StreamManager): + """Set a read-only stream manager (for loaded sessions, no streaming).""" + self._stream_mgr = mgr + + def clear_manager(self): + """Clear the stream manager without stopping processes.""" + self._stream_mgr = None + + # -- Internal callbacks -- + + def _on_duration_update(self, duration): + GLib.idle_add(self._timeline.set_duration, duration) + if not self._gone_live: + self._gone_live = True + GLib.idle_add(self._go_live_once) + if self._stream_mgr: + self._stream_mgr.capture_now(on_new_frames=self._handle_new_scene_frames) + + def _go_live_once(self): + if self._stream_mgr: + log.info("Going LIVE (startup delay elapsed)") + self._timeline.go_live() + return False + + def _tick_live(self): + if not self._streaming: + return False + self._timeline.tick_live() + return True + + def _handle_new_scene_frames(self, frames): + for f in frames: + GLib.idle_add(self._on_scene_marker, f["timestamp"]) + self._on_new_frames(frames) + + def _handle_new_audio(self, wav_path, start_time, duration): + if not self._stream_mgr: + return + self._waveform_engine.append_chunk(wav_path, start_time) + peaks = self._waveform_engine.peaks + bucket_dur = self._waveform_engine.bucket_duration + GLib.idle_add(self._on_waveform_update, peaks.copy(), bucket_dur) + + self._pending_transcript_audio.append((wav_path, start_time, duration)) + self._pending_transcript_duration += duration + if self._pending_transcript_duration < TRANSCRIBE_MIN_CHUNK_S: + return + + first_start = self._pending_transcript_audio[0][1] + 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_start):06d}.wav" + + def _transcribe(): + from cht.stream import ffmpeg as ff + try: + ff.extract_audio_chunk( + mgr.recording_path, chunk_wav, + start_time=first_start, duration=total_dur, + ) + except Exception as e: + log.error("Transcript audio extraction failed: %s", e) + return + if not chunk_wav.exists(): + return + new_segs = self._transcriber.transcribe_chunk(chunk_wav, time_offset=first_start) + 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").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) + return True diff --git a/cht/ui/agent_input.py b/cht/ui/agent_input.py new file mode 100644 index 0000000..b2a7fe0 --- /dev/null +++ b/cht/ui/agent_input.py @@ -0,0 +1,125 @@ +"""Agent input panel — entry, action buttons, model/language dropdowns.""" + +import logging + +import gi +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk, GObject + +from cht.agent.runner import ACTIONS +from cht.transcriber.engine import LANGUAGES + +log = logging.getLogger(__name__) + + +class AgentInputPanel(Gtk.Frame): + """Input bar with action buttons, model/lang selectors, and text entry.""" + + __gsignals__ = { + "send-requested": (GObject.SignalFlags.RUN_FIRST, None, (str,)), + "action-requested": (GObject.SignalFlags.RUN_FIRST, None, (str,)), + "model-changed": (GObject.SignalFlags.RUN_FIRST, None, (str,)), + "lang-changed": (GObject.SignalFlags.RUN_FIRST, None, (str,)), + "history-toggled": (GObject.SignalFlags.RUN_FIRST, None, (bool,)), + } + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + outer.set_margin_start(4) + outer.set_margin_end(4) + outer.set_margin_top(4) + outer.set_margin_bottom(4) + + actions_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + for label, verb in ACTIONS.items(): + btn = Gtk.Button(label=label) + btn.add_css_class("flat") + btn.connect("clicked", lambda b, v=verb: self.emit("action-requested", v)) + actions_box.append(btn) + + spacer = Gtk.Box() + spacer.set_hexpand(True) + actions_box.append(spacer) + + model_label = Gtk.Label(label="Model:") + model_label.add_css_class("dim-label") + actions_box.append(model_label) + + self._model_dropdown = Gtk.DropDown.new_from_strings([]) + self._model_dropdown.set_size_request(200, -1) + self._model_dropdown.connect("notify::selected", self._on_model_changed) + actions_box.append(self._model_dropdown) + + lang_label = Gtk.Label(label="Lang:") + lang_label.add_css_class("dim-label") + actions_box.append(lang_label) + + lang_names = list(LANGUAGES.keys()) + self._lang_names = lang_names + self._lang_dropdown = Gtk.DropDown.new_from_strings(lang_names) + self._lang_dropdown.set_selected(0) + self._lang_dropdown.connect("notify::selected", self._on_lang_changed) + actions_box.append(self._lang_dropdown) + + history_toggle = Gtk.CheckButton(label="Chat") + history_toggle.set_tooltip_text("Include conversation history in prompts") + history_toggle.connect("toggled", lambda b: self.emit("history-toggled", b.get_active())) + actions_box.append(history_toggle) + + outer.append(actions_box) + + input_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + self._entry = Gtk.Entry() + self._entry.set_hexpand(True) + self._entry.set_placeholder_text("Message agent... (@F1-3 frames, @T1-5 transcript)") + self._entry.connect("activate", lambda e: self._do_send()) + input_row.append(self._entry) + + send_btn = Gtk.Button(label="Send") + send_btn.add_css_class("suggested-action") + send_btn.connect("clicked", lambda b: self._do_send()) + input_row.append(send_btn) + outer.append(input_row) + + self.set_child(outer) + + @property + def entry(self) -> Gtk.Entry: + """The text entry widget (for focus checks).""" + return self._entry + + def get_text(self) -> str: + return self._entry.get_text().strip() + + def clear_text(self) -> None: + self._entry.set_text("") + + def populate_models(self, models: list[str], current: str | None = None) -> None: + if not models: + return + string_list = Gtk.StringList.new(models) + self._model_dropdown.set_model(string_list) + if current: + for i, m in enumerate(models): + if m == current: + self._model_dropdown.set_selected(i) + break + + def _do_send(self): + text = self.get_text() + self.clear_text() + self.emit("send-requested", text) + + def _on_model_changed(self, dropdown, _pspec): + idx = dropdown.get_selected() + model = dropdown.get_model() + if model and idx < model.get_n_items(): + self.emit("model-changed", model.get_string(idx)) + + def _on_lang_changed(self, dropdown, _pspec): + idx = dropdown.get_selected() + if idx < len(self._lang_names): + lang_code = LANGUAGES[self._lang_names[idx]] + self.emit("lang-changed", lang_code or "") diff --git a/cht/ui/session_dialog.py b/cht/ui/session_dialog.py new file mode 100644 index 0000000..37d7792 --- /dev/null +++ b/cht/ui/session_dialog.py @@ -0,0 +1,106 @@ +"""Session browser dialog — lists sessions, supports load and delete.""" + +import json +import logging + +import gi +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Gtk, Adw, GObject + +from cht.stream.manager import list_sessions, delete_sessions + +log = logging.getLogger(__name__) + + +class SessionDialog(Adw.Window): + """Modal dialog listing sessions. Emits 'session-selected' with session id.""" + + __gsignals__ = { + "session-selected": (GObject.SignalFlags.RUN_FIRST, None, (str,)), + } + + def __init__(self, parent, **kwargs): + super().__init__(transient_for=parent, modal=True, **kwargs) + self.set_title("Load Session") + self.set_default_size(500, 400) + + sessions = list_sessions() + + toolbar = Adw.ToolbarView() + header = Adw.HeaderBar() + + select_all_btn = Gtk.CheckButton(label="All") + header.pack_start(select_all_btn) + + delete_btn = Gtk.Button(label="Delete") + delete_btn.add_css_class("destructive-action") + header.pack_end(delete_btn) + + toolbar.add_top_bar(header) + + scroll = Gtk.ScrolledWindow() + scroll.set_vexpand(True) + listbox = Gtk.ListBox() + listbox.set_selection_mode(Gtk.SelectionMode.NONE) + listbox.add_css_class("boxed-list") + + checks: list[tuple[str, Gtk.CheckButton]] = [] + + for sid, sdir in sessions: + idx = sdir / "frames" / "index.json" + nframes = 0 + try: + nframes = len(json.loads(idx.read_text())) + except Exception: + pass + nrec = len(list((sdir / "stream").glob("recording_*.mp4"))) + + check = Gtk.CheckButton() + checks.append((sid, check)) + + row = Adw.ActionRow() + row.set_title(sid) + row.set_subtitle(f"{nframes} frames, {nrec} segments") + row.set_activatable(True) + row.add_prefix(check) + + def _on_row_activated(r, s=sid): + self.close() + self.emit("session-selected", s) + row.connect("activated", _on_row_activated) + listbox.append(row) + + def _on_select_all(btn): + active = btn.get_active() + for _, cb in checks: + cb.set_active(active) + select_all_btn.connect("toggled", _on_select_all) + + def _on_delete(btn): + to_delete = [sid for sid, cb in checks if cb.get_active()] + if not to_delete: + return + if self._current_session in to_delete: + to_delete.remove(self._current_session) + if to_delete: + delete_sessions(to_delete) + self.close() + # Re-open with updated list + new_dialog = SessionDialog(self.get_transient_for()) + new_dialog._current_session = self._current_session + # Forward the signal + new_dialog.connect("session-selected", + lambda d, s: self.emit("session-selected", s)) + new_dialog.present() + delete_btn.connect("clicked", _on_delete) + + scroll.set_child(listbox) + toolbar.set_content(scroll) + self.set_content(toolbar) + + self._current_session = None + + def set_current_session(self, session_id: str | None) -> None: + """Set the active session id so it won't be deleted.""" + self._current_session = session_id diff --git a/cht/window.py b/cht/window.py index df8b57e..5f5f5af 100644 --- a/cht/window.py +++ b/cht/window.py @@ -1,18 +1,16 @@ """Main application window — wires Timeline to all components.""" -import json import logging -from pathlib import Path import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") gi.require_version("GdkPixbuf", "2.0") -from gi.repository import Gtk, Gdk, Adw, GLib, Pango, GdkPixbuf +from gi.repository import Gtk, Gdk, Adw, GLib, GdkPixbuf from threading import Thread -from cht.config import APP_NAME, SCENE_THRESHOLD, TRANSCRIBE_MIN_CHUNK_S +from cht.config import APP_NAME from cht.ui.timeline import Timeline, TimelineControls from cht.ui.monitor import MonitorWidget from cht.ui.waveform import WaveformWidget @@ -20,11 +18,14 @@ from cht.ui.frames_panel import FramesPanel from cht.ui.transcript_panel import TranscriptPanel from cht.ui.keyboard import KeyboardManager, KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN, KEY_RETURN, KEY_KP_ENTER, KEY_ESCAPE, KEY_DELETE from cht.ui.agent_output import AgentOutputPanel +from cht.ui.agent_input import AgentInputPanel from cht.audio.waveform import WaveformEngine -from cht.transcriber.engine import TranscriberEngine, LANGUAGES -from cht.stream.manager import StreamManager, list_sessions, delete_sessions -from cht.stream.tracker import RecordingTracker -from cht.agent.runner import AgentRunner, ACTIONS, check_claude_cli +from cht.transcriber.engine import TranscriberEngine +from cht.stream.manager import StreamManager, list_sessions +from cht.stream.lifecycle import StreamLifecycle +from cht.ui.session_dialog import SessionDialog +from cht.session import load_frame_index +from cht.agent.runner import AgentRunner, check_claude_cli log = logging.getLogger(__name__) @@ -34,10 +35,6 @@ class ChtWindow(Adw.ApplicationWindow): super().__init__(**kwargs) self.set_title(APP_NAME) self.set_default_size(1400, 900) - self._streaming = False - self._gone_live = False - self._stream_mgr = None - self._tracker = None self._known_frames = set() # Core components @@ -45,8 +42,19 @@ class ChtWindow(Adw.ApplicationWindow): self._agent = AgentRunner() self._waveform_engine = WaveformEngine() self._transcriber = TranscriberEngine() - self._pending_transcript_audio = [] - self._pending_transcript_duration = 0.0 + + # Stream lifecycle (owns streaming state, recorder, tracker, audio buffering) + # Lambdas used because panels/widgets aren't created yet at this point. + self._lifecycle = StreamLifecycle( + timeline=self._timeline, + waveform_engine=self._waveform_engine, + transcriber=self._transcriber, + on_new_frames=lambda frames: None, # frame polling handles new frames + on_waveform_update=lambda peaks, bd: self._waveform_widget.set_peaks(peaks, bd), + on_transcript_ready=lambda segs: self._transcript_panel.add_items(segs), + on_scene_marker=lambda ts: self._timeline.add_scene_marker(ts), + on_recorder_restarted=lambda path: self._monitor.set_recording(path), + ) # Panels (own their selection state) self._frames_panel = FramesPanel() @@ -112,21 +120,23 @@ class ChtWindow(Adw.ApplicationWindow): # -- Connect / Disconnect -- def _on_connect_clicked(self, button): - if self._streaming: + if self._lifecycle.is_streaming: self._stop_stream(reload_session=True) else: - session_id = self._stream_mgr.session_id if self._stream_mgr else None - if self._stream_mgr: + session_id = self._lifecycle.stream_mgr.session_id if self._lifecycle.stream_mgr else None + if self._lifecycle.stream_mgr: self._stop_stream() self._start_stream(session_id=session_id) def _on_capture_clicked(self): - if self._stream_mgr: - self._stream_mgr.capture_now(on_new_frames=self._on_new_scene_frames) + if self._lifecycle.stream_mgr: + self._lifecycle.stream_mgr.capture_now( + on_new_frames=self._lifecycle._handle_new_scene_frames + ) def _on_scene_threshold(self, val): - if self._stream_mgr: - self._stream_mgr.scene_threshold = val + if self._lifecycle.stream_mgr: + self._lifecycle.stream_mgr.scene_threshold = val def _on_min_chunk_changed(self, panel, val): import cht.config @@ -143,97 +153,33 @@ class ChtWindow(Adw.ApplicationWindow): if not sessions: self._agent_output.append("No previous sessions found.\n") return - - dialog = Adw.Window(transient_for=self, modal=True) - dialog.set_title("Load Session") - dialog.set_default_size(500, 400) - - toolbar = Adw.ToolbarView() - header = Adw.HeaderBar() - - select_all_btn = Gtk.CheckButton(label="All") - header.pack_start(select_all_btn) - - delete_btn = Gtk.Button(label="Delete") - delete_btn.add_css_class("destructive-action") - header.pack_end(delete_btn) - - toolbar.add_top_bar(header) - - scroll = Gtk.ScrolledWindow() - scroll.set_vexpand(True) - listbox = Gtk.ListBox() - listbox.set_selection_mode(Gtk.SelectionMode.NONE) - listbox.add_css_class("boxed-list") - - checks: list[tuple[str, Gtk.CheckButton]] = [] - - for sid, sdir in sessions: - idx = sdir / "frames" / "index.json" - nframes = 0 - try: - nframes = len(json.loads(idx.read_text())) - except Exception: - pass - nrec = len(list((sdir / "stream").glob("recording_*.mp4"))) - - check = Gtk.CheckButton() - checks.append((sid, check)) - - row = Adw.ActionRow() - row.set_title(sid) - row.set_subtitle(f"{nframes} frames, {nrec} segments") - row.set_activatable(True) - row.add_prefix(check) - - def _on_row_activated(r, s=sid, d=dialog): - d.close() - self._load_session(s) - row.connect("activated", _on_row_activated) - listbox.append(row) - - def _on_select_all(btn): - active = btn.get_active() - for _, cb in checks: - cb.set_active(active) - select_all_btn.connect("toggled", _on_select_all) - - def _on_delete(btn): - to_delete = [sid for sid, cb in checks if cb.get_active()] - if not to_delete: - return - current = self._stream_mgr.session_id if self._stream_mgr else None - if current in to_delete: - to_delete.remove(current) - if to_delete: - delete_sessions(to_delete) - dialog.close() - self._on_load_session_clicked(None) - delete_btn.connect("clicked", _on_delete) - - scroll.set_child(listbox) - toolbar.set_content(scroll) - dialog.set_content(toolbar) + dialog = SessionDialog(self) + dialog.set_current_session( + self._lifecycle.stream_mgr.session_id if self._lifecycle.stream_mgr else None + ) + dialog.connect("session-selected", lambda d, sid: self._load_session(sid)) dialog.present() def _load_session(self, session_id): """Load an existing session for review (no streaming).""" - if self._streaming or self._stream_mgr: + if self._lifecycle.is_streaming or self._lifecycle.stream_mgr: self._stop_stream() try: - self._stream_mgr = StreamManager.from_existing(session_id) + mgr = StreamManager.from_existing(session_id) except FileNotFoundError as e: self._agent_output.append(f"Error: {e}\n") return + self._lifecycle.set_manager_readonly(mgr) + self.set_title(f"{APP_NAME} — {session_id}") self._agent_output.append(f"Loaded session: {session_id}\n") - segments = self._stream_mgr.recording_segments + segments = mgr.recording_segments if segments: self._monitor.set_recording(segments[0]) - duration = self._stream_mgr.total_duration() + duration = mgr.total_duration() if duration > 0: self._timeline.set_duration(duration) self._timeline.seek(0) @@ -252,7 +198,7 @@ class ChtWindow(Adw.ApplicationWindow): from cht.stream import ffmpeg as ff def _compute_waveform(): - audio_dir = self._stream_mgr.audio_dir + audio_dir = mgr.audio_dir audio_dir.mkdir(parents=True, exist_ok=True) full_wav = audio_dir / "full.wav" try: @@ -275,125 +221,32 @@ class ChtWindow(Adw.ApplicationWindow): self._connect_btn.set_label("Disconnect") self._connect_btn.remove_css_class("suggested-action") self._connect_btn.add_css_class("destructive-action") - self._streaming = True - self._gone_live = False - self._stream_mgr = StreamManager(session_id=session_id) - self._stream_mgr.setup_dirs() - self._stream_mgr.start_recorder() + mgr = self._lifecycle.start(session_id=session_id) - self._monitor.set_recording(self._stream_mgr.recording_path) - self._monitor.set_live_source(self._stream_mgr.relay_url) - - self._tracker = RecordingTracker( - get_segments=lambda: self._stream_mgr.recording_segments if self._stream_mgr else [], - on_duration_update=self._on_duration_update, - ) - self._tracker.start() - - self._stream_mgr.start_scene_detector(on_new_frames=self._on_new_scene_frames) - self._stream_mgr.start_audio_extractor(on_new_audio=self._on_new_audio) + self._monitor.set_recording(mgr.recording_path) + self._monitor.set_live_source(mgr.relay_url) GLib.timeout_add(1000, self._poll_frames) - GLib.timeout_add(1000, self._tick_live) - GLib.timeout_add(2000, self._check_recorder) # Reload existing data if resuming if session_id: self._load_existing_frames() self._load_existing_transcript() - self.set_title(f"{APP_NAME} — {self._stream_mgr.session_id}") + self.set_title(f"{APP_NAME} — {mgr.session_id}") log.info("Waiting for sender...") - def _go_live_once(self): - if self._stream_mgr: - log.info("Going LIVE (startup delay elapsed)") - self._timeline.go_live() - return False - - def _tick_live(self): - if not self._streaming: - return False - self._timeline.tick_live() - return True - - def _on_duration_update(self, duration): - GLib.idle_add(self._timeline.set_duration, duration) - if not self._gone_live: - self._gone_live = True - GLib.idle_add(self._go_live_once) - if self._stream_mgr: - self._stream_mgr.capture_now(on_new_frames=self._on_new_scene_frames) - - def _on_new_scene_frames(self, frames): - for f in frames: - GLib.idle_add(self._timeline.add_scene_marker, f["timestamp"]) - - def _on_new_audio(self, wav_path, start_time, duration): - if not self._stream_mgr: - return - self._waveform_engine.append_chunk(wav_path, start_time) - peaks = self._waveform_engine.peaks - bucket_dur = self._waveform_engine.bucket_duration - GLib.idle_add(self._waveform_widget.set_peaks, peaks.copy(), bucket_dur) - - self._pending_transcript_audio.append((wav_path, start_time, duration)) - self._pending_transcript_duration += duration - if self._pending_transcript_duration < TRANSCRIBE_MIN_CHUNK_S: - return - - first_start = self._pending_transcript_audio[0][1] - 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_start):06d}.wav" - - def _transcribe(): - from cht.stream import ffmpeg as ff - try: - ff.extract_audio_chunk( - mgr.recording_path, chunk_wav, - start_time=first_start, duration=total_dur, - ) - except Exception as e: - log.error("Transcript audio extraction failed: %s", e) - return - if not chunk_wav.exists(): - return - new_segs = self._transcriber.transcribe_chunk(chunk_wav, time_offset=first_start) - self._transcriber.save_index(mgr.transcript_dir / "index.json") - if new_segs: - GLib.idle_add(self._transcript_panel.add_items, new_segs) - - Thread(target=_transcribe, daemon=True, name="transcriber").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._monitor.set_recording(self._stream_mgr.recording_path) - return True - def _on_live_toggle(self): pos = self._monitor.get_live_position() self._timeline.toggle_live(live_player_pos=pos) def _stop_stream(self, reload_session=False): log.info("Stopping stream...") - last_session_id = self._stream_mgr.session_id if self._stream_mgr and not self._stream_mgr.readonly else None + mgr = self._lifecycle.stream_mgr + last_session_id = mgr.session_id if mgr and not mgr.readonly else None - if self._tracker: - self._tracker.stop() - self._tracker = None - if self._stream_mgr: - if not self._stream_mgr.readonly: - self._stream_mgr.stop_all() - self._stream_mgr = None + self._lifecycle.stop() self._timeline.reset() self._monitor.reset() @@ -401,8 +254,6 @@ class ChtWindow(Adw.ApplicationWindow): self._waveform_widget.set_peaks(None, 0.05) self._transcriber.reset() self._agent.clear_history() - self._pending_transcript_audio.clear() - self._pending_transcript_duration = 0.0 self._known_frames = set() self._frames_panel.clear() @@ -411,7 +262,6 @@ class ChtWindow(Adw.ApplicationWindow): self._connect_btn.set_label("Connect") self._connect_btn.remove_css_class("destructive-action") self._connect_btn.add_css_class("suggested-action") - self._streaming = False self.set_title(APP_NAME) if reload_session and last_session_id: @@ -422,7 +272,7 @@ class ChtWindow(Adw.ApplicationWindow): def teardown(self): """Full cleanup for app exit — safe to call multiple times.""" - if self._stream_mgr or self._streaming: + if self._lifecycle.stream_mgr or self._lifecycle.is_streaming: self._stop_stream() self._monitor.stop() @@ -465,72 +315,16 @@ class ChtWindow(Adw.ApplicationWindow): right_box.append(transcript_frame) # Agent input - self._agent_input = self._build_agent_input() + self._agent_input = AgentInputPanel() + self._agent_input.connect("send-requested", lambda p, text: self._send_message(text or None)) + self._agent_input.connect("action-requested", lambda p, verb: self._send_action(verb)) + self._agent_input.connect("model-changed", self._on_model_changed) + self._agent_input.connect("lang-changed", self._on_lang_changed) + self._agent_input.connect("history-toggled", lambda p, v: setattr(self._agent, "include_history", v)) right_box.append(self._agent_input) return right_box - def _build_agent_input(self): - outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) - outer.set_margin_start(4) - outer.set_margin_end(4) - outer.set_margin_top(4) - outer.set_margin_bottom(4) - - actions_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) - for label, verb in ACTIONS.items(): - btn = Gtk.Button(label=label) - btn.add_css_class("flat") - btn.connect("clicked", lambda b, v=verb: self._send_action(v)) - actions_box.append(btn) - - spacer = Gtk.Box() - spacer.set_hexpand(True) - actions_box.append(spacer) - - model_label = Gtk.Label(label="Model:") - model_label.add_css_class("dim-label") - actions_box.append(model_label) - - self._model_dropdown = Gtk.DropDown.new_from_strings([]) - self._model_dropdown.set_size_request(200, -1) - self._model_dropdown.connect("notify::selected", self._on_model_changed) - actions_box.append(self._model_dropdown) - - lang_label = Gtk.Label(label="Lang:") - lang_label.add_css_class("dim-label") - actions_box.append(lang_label) - - lang_names = list(LANGUAGES.keys()) - self._lang_dropdown = Gtk.DropDown.new_from_strings(lang_names) - self._lang_dropdown.set_selected(0) - self._lang_dropdown.connect("notify::selected", self._on_lang_changed) - actions_box.append(self._lang_dropdown) - - self._history_toggle = Gtk.CheckButton(label="Chat") - self._history_toggle.set_tooltip_text("Include conversation history in prompts") - self._history_toggle.connect("toggled", lambda b: setattr(self._agent, "include_history", b.get_active())) - actions_box.append(self._history_toggle) - - outer.append(actions_box) - - input_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) - self._input_entry = Gtk.Entry() - self._input_entry.set_hexpand(True) - self._input_entry.set_placeholder_text("Message agent... (@F1-3 frames, @T1-5 transcript)") - self._input_entry.connect("activate", lambda e: self._send_message()) - input_row.append(self._input_entry) - - send_btn = Gtk.Button(label="Send") - send_btn.add_css_class("suggested-action") - send_btn.connect("clicked", lambda b: self._send_message()) - input_row.append(send_btn) - outer.append(input_row) - - frame = Gtk.Frame() - frame.set_child(outer) - return frame - # -- Keyboard -- def _setup_keyboard(self): @@ -541,7 +335,7 @@ class ChtWindow(Adw.ApplicationWindow): return False w = focus while w is not None: - if w is self._input_entry: + if w is self._agent_input.entry: return True w = w.get_parent() return False @@ -575,14 +369,11 @@ class ChtWindow(Adw.ApplicationWindow): self._send_message(msg) def _send_message(self, text: str | None = None): - if text is None: - text = self._input_entry.get_text().strip() - self._input_entry.set_text("") if not text: text = self._build_selection_message("answer") if not text: return - if not self._stream_mgr: + if not self._lifecycle.stream_mgr: self._agent_output.append("No active session.\n") return @@ -591,40 +382,26 @@ class ChtWindow(Adw.ApplicationWindow): self._agent_output.begin_response() self._agent.send( message=text, - stream_mgr=self._stream_mgr, - tracker=self._tracker, + stream_mgr=self._lifecycle.stream_mgr, + tracker=self._lifecycle.tracker, on_chunk=lambda chunk: GLib.idle_add(self._agent_output.replace_thinking, chunk), on_done=lambda err: GLib.idle_add(self._agent_output.finish_response, err), ) # -- Settings callbacks -- - def _on_lang_changed(self, dropdown, _pspec): - idx = dropdown.get_selected() - lang_names = list(LANGUAGES.keys()) - if idx < len(lang_names): - lang_code = LANGUAGES[lang_names[idx]] - self._transcriber.language = lang_code - log.info("Transcript language: %s (%s)", lang_names[idx], lang_code or "auto") + def _on_lang_changed(self, _panel, lang_code): + self._transcriber.language = lang_code or None + log.info("Transcript language: %s", lang_code or "auto") - def _on_model_changed(self, dropdown, _pspec): - idx = dropdown.get_selected() - model = self._agent.available_models[idx] if idx < len(self._agent.available_models) else None - if model: - self._agent.model = model - log.info("Model switched to %s", model) + def _on_model_changed(self, _panel, model): + self._agent.model = model + log.info("Model switched to %s", model) def _populate_model_dropdown(self): - models = self._agent.available_models - if not models: - return - string_list = Gtk.StringList.new(models) - self._model_dropdown.set_model(string_list) - current = self._agent.model - for i, m in enumerate(models): - if m == current: - self._model_dropdown.set_selected(i) - break + self._agent_input.populate_models( + self._agent.available_models, self._agent.model + ) def _check_agent_auth(self): import os @@ -641,26 +418,17 @@ class ChtWindow(Adw.ApplicationWindow): # -- Data loading -- def _load_existing_frames(self): - if not self._stream_mgr: + if not self._lifecycle.stream_mgr: return - index_path = self._stream_mgr.frames_dir / "index.json" - if not index_path.exists(): + entries = load_frame_index(self._lifecycle.stream_mgr.frames_dir) + if not entries: self._agent_output.append(" No frames found.\n") return - try: - index = json.loads(index_path.read_text()) - except (json.JSONDecodeError, IOError): - return items = [] - for entry in index: - fpath = Path(entry["path"]) - if not fpath.exists(): - fpath = self._stream_mgr.frames_dir / fpath.name - if not fpath.exists(): - continue + for entry in entries: try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(fpath), 256, 144, True) - items.append({"id": entry["id"], "pixbuf": pixbuf, "timestamp": entry.get("timestamp", 0)}) + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(entry["path"]), 256, 144, True) + items.append({"id": entry["id"], "pixbuf": pixbuf, "timestamp": entry["timestamp"]}) except Exception as e: log.warning("Thumbnail load failed for %s: %s", entry["id"], e) if items: @@ -669,9 +437,9 @@ class ChtWindow(Adw.ApplicationWindow): self._agent_output.append(f" Loaded {len(items)} frame thumbnails.\n") def _load_existing_transcript(self): - if not self._stream_mgr: + if not self._lifecycle.stream_mgr: return - transcript_index = self._stream_mgr.transcript_dir / "index.json" + transcript_index = self._lifecycle.stream_mgr.transcript_dir / "index.json" if not transcript_index.exists(): return self._transcriber.load_index(transcript_index) @@ -681,28 +449,17 @@ class ChtWindow(Adw.ApplicationWindow): self._agent_output.append(f" Loaded {len(segs)} transcript segments.\n") def _poll_frames(self): - if not self._stream_mgr: + if not self._lifecycle.stream_mgr: return False - index_path = self._stream_mgr.frames_dir / "index.json" - if not index_path.exists(): - return True - try: - index = json.loads(index_path.read_text()) - except (json.JSONDecodeError, IOError): - return True - for entry in index: + for entry in load_frame_index(self._lifecycle.stream_mgr.frames_dir): fid = entry["id"] if fid in self._known_frames: continue - fpath = Path(entry["path"]) - if not fpath.exists(): - continue self._known_frames.add(fid) - timestamp = entry.get("timestamp", 0) try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(fpath), 256, 144, True) + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(entry["path"]), 256, 144, True) auto = not self._transcript_panel.has_selection - self._frames_panel.add_item(fid, pixbuf, timestamp, auto_select=auto) + self._frames_panel.add_item(fid, pixbuf, entry["timestamp"], auto_select=auto) except Exception as e: log.warning("Thumbnail load failed for %s: %s", fid, e) return True