audio and transcript

This commit is contained in:
2026-04-02 22:57:21 -03:00
parent 0b5575f3b3
commit d61e2a5492
13 changed files with 556 additions and 11 deletions

107
cht/ui/waveform.py Normal file
View File

@@ -0,0 +1,107 @@
"""
WaveformWidget: GTK4 DrawingArea that renders waveform peaks with a playhead.
Driven by Timeline "changed" signal — redraws when cursor or duration changes.
Peak data is set externally via set_peaks() from GLib.idle_add.
"""
import logging
import math
import numpy as np
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk, GLib
log = logging.getLogger(__name__)
class WaveformWidget(Gtk.Box):
"""Waveform display synced to Timeline state."""
def __init__(self, timeline, **kwargs):
super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs)
self._timeline = timeline
self._peaks = None
self._bucket_duration = 0.05
label = Gtk.Label(label="Waveform")
label.add_css_class("heading")
label.set_margin_top(4)
label.set_margin_bottom(4)
self.append(label)
self._area = Gtk.DrawingArea()
self._area.set_content_height(250)
self._area.set_hexpand(True)
self._area.set_vexpand(True)
self._area.set_draw_func(self._draw)
self.append(self._area)
timeline.connect("changed", self._on_timeline_changed)
def set_peaks(self, peaks, bucket_duration):
"""Update peak data. Call from GLib.idle_add."""
self._peaks = peaks
self._bucket_duration = bucket_duration
self._area.queue_draw()
def _on_timeline_changed(self, timeline):
self._area.queue_draw()
def _draw(self, area, cr, width, height):
# Background
cr.set_source_rgb(0.1, 0.1, 0.12)
cr.rectangle(0, 0, width, height)
cr.fill()
state = self._timeline.state
duration = state.duration
mid_y = height / 2
# Center line
cr.set_source_rgba(0.3, 0.3, 0.35, 1.0)
cr.set_line_width(1)
cr.move_to(0, mid_y)
cr.line_to(width, mid_y)
cr.stroke()
# Draw peaks
if self._peaks is not None and len(self._peaks) > 0 and duration > 0:
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
for x in range(width):
# Time at this pixel
t = (x / width) * duration
# Corresponding peak index
idx = int(t / self._bucket_duration)
if 0 <= idx < n_peaks:
val = self._peaks[idx] / max_peak
bar_h = val * (height * 0.45) # 90% of half-height
# Green gradient based on amplitude
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.fill()
# Scene markers
if duration > 0:
cr.set_source_rgba(1.0, 1.0, 0.3, 0.3)
cr.set_line_width(1)
for marker in state.scene_markers:
mx = (marker / duration) * width
cr.move_to(mx, 0)
cr.line_to(mx, height)
cr.stroke()
# Playhead
if duration > 0:
px = (state.cursor / duration) * width
cr.set_source_rgba(1.0, 0.3, 0.3, 0.9)
cr.set_line_width(2)
cr.move_to(px, 0)
cr.line_to(px, height)
cr.stroke()