package mujica import ( "log/slog" "math" "time" "github.com/egoist/mygo/ui" "mujicaui/internal" "mujicaui/theme" ) // AudioWaveformOptions configure an AudioWaveform. type AudioWaveformOptions struct { // Duration is the audio's length; required when data is ready. Duration time.Duration // Position draws the played part; OnSeek reports clicks and arrows. Position time.Duration OnSeek func(time.Duration) // Selection, when set, is chosen by dragging or Shift+arrows. Selection *MediaRange // Height is the chart's height; zero means 96. Height float32 Status DataStatus Error string Label string } // waveformPeaks caches column peaks until data or width change. type waveformPeaks struct { first *float32 n int cols int peaks []float32 } type waveformKey struct{} type waveformPress struct { on, dragged bool x float32 } // waveformBuckets counts peak rebuilds, for tests. var waveformBuckets int // AudioWaveform draws samples with played part and selectable range. func AudioWaveform(c *ui.Context, samples []float32, opts AudioWaveformOptions) ChoiceResult { h := opts.Height if h < 0 || opts.Position < 0 { panic("mujica: AudioWaveform Height and Position must not be negative") } if h == 0 { h = 96 } k := Tokens(c) label := opts.Label if label == "" { label = msg(c, "audio_waveform.label") } e := ui.Box(c).Height(h).MinWidth(120).Radius(theme.ControlRadius).Background(k.Surface). Border(theme.BorderWidth, k.Border).Focusable().Label(label).Cursor(ui.CursorText) internal.Ring(e, k.Focus, theme.ControlRadius) v := ChoiceResult{Element: e} if mediaChartState(c, e, opts.Status, opts.Error, len(samples) == 0, "audio_waveform.empty") { return v } if opts.Duration <= 0 || opts.OnSeek == nil || opts.Position > opts.Duration { panic("mujica: AudioWaveform needs Duration > 0, OnSeek and Position <= Duration") } if s := opts.Selection; s != nil && (s.Start < 0 || s.End < s.Start || s.End > opts.Duration) { panic("mujica: AudioWaveform Selection outside 0..Duration") } r := e.Bounds() cols := int(r.W) cache := ui.Local(e, waveformKey{}, func() waveformPeaks { return waveformPeaks{} }) if cols <= 0 { // Peaks need the width: lay out first, then build again. c.AnimationFrame() } else if cache.first != &samples[0] || cache.n != len(samples) || cache.cols != cols { *cache = waveformPeaks{&samples[0], len(samples), cols, waveformBucket(samples, cols)} } if !e.IsDisabled() { v.changed = waveformInput(c, e, r, opts) } e.Description(mediaClock(opts.Position) + " / " + mediaClock(opts.Duration)) peaks, pos, sel := cache.peaks, float32(opts.Position)/float32(opts.Duration), opts.Selection dur := float32(opts.Duration) e.Draw(func(p *ui.Painter, r ui.Rect) { mid := r.Y + r.H/2 amp := r.H/2 - 6 if sel != nil && sel.End > sel.Start { x0, x1 := r.X+r.W*float32(sel.Start)/dur, r.X+r.W*float32(sel.End)/dur p.Fill(ui.Rect{X: x0, Y: r.Y, W: x1 - x0, H: r.H}, k.Selection, 0) p.Fill(ui.Rect{X: x0, Y: r.Y, W: 1, H: r.H}, k.AccentText, 0) p.Fill(ui.Rect{X: x1 - 1, Y: r.Y, W: 1, H: r.H}, k.AccentText, 0) } played := r.X + r.W*pos for i, pk := range peaks { x := r.X + r.W*float32(i)/float32(len(peaks)) col := k.TextMuted if x < played { col = k.AccentText } ph := max(1, amp*pk) p.Fill(ui.Rect{X: x, Y: mid - ph, W: max(1, r.W/float32(len(peaks))-0.5), H: 2 * ph}, col, 0) } px := internal.Snap(p, played) p.Fill(ui.Rect{X: px - 1, Y: r.Y, W: 2, H: r.H}, k.Text, 0) p.FillPath(internal.Diamond(px, internal.Snap(p, r.Y+5), 8), k.Text) }) return v } // waveformBucket returns the largest magnitude in each of cols spans. func waveformBucket(samples []float32, cols int) []float32 { waveformBuckets++ peaks := make([]float32, cols) for i, s := range samples { col := i * cols / len(samples) peaks[col] = max(peaks[col], float32(math.Abs(float64(s)))) } return peaks } // waveformInput seeks and selects; true means the selection changed. func waveformInput(c *ui.Context, e *ui.Element, r ui.Rect, opts AudioWaveformOptions) bool { at := func(x float32) time.Duration { f := max(0, min(1, (x-r.X)/max(1, r.W))) return time.Duration(float64(f) * float64(opts.Duration)).Round(time.Millisecond) } seek := func(t time.Duration) { t = max(0, min(opts.Duration, t)) slog.Debug("mujica: audio_waveform seek", "pos", t) opts.OnSeek(t) } sel := opts.Selection old := MediaRange{} if sel != nil { old = *sel } px, _, _ := e.PointerPosition() px += r.X st := ui.Local(e, waveformPress{}, func() waveformPress { return waveformPress{} }) _, _, dragging := e.Dragged() switch { case e.Pressed() && !st.on: *st = waveformPress{on: true, x: px} case e.Pressed() && sel != nil && (dragging || st.dragged) && math.Abs(float64(px-st.x)) > 3: st.dragged = true a, b := at(st.x), at(px) *sel = MediaRange{min(a, b), max(a, b)} case !e.Pressed() && st.on: if !st.dragged { seek(at(st.x)) } *st = waveformPress{} } const step = 5 * time.Second switch { case e.Shortcut(0, ui.KeyLeft): seek(opts.Position - step) case e.Shortcut(0, ui.KeyRight): seek(opts.Position + step) case sel != nil && e.Shortcut(ui.Shift, ui.KeyRight): *sel = waveformExtend(*sel, opts.Position, step, opts.Duration) case sel != nil && e.Shortcut(ui.Shift, ui.KeyLeft): *sel = waveformExtend(*sel, opts.Position, -step, opts.Duration) case sel != nil && e.Shortcut(0, ui.KeyEscape): *sel = MediaRange{} } changed := sel != nil && *sel != old if changed { slog.Debug("mujica: audio_waveform select", "start", sel.Start, "end", sel.End) } return changed } // waveformExtend starts a selection at pos or moves its end. func waveformExtend(s MediaRange, pos, d, dur time.Duration) MediaRange { if s.End <= s.Start { s = MediaRange{pos, pos} } s.End = max(s.Start, min(dur, s.End+d)) return s }