188 lines
5.8 KiB
Go
188 lines
5.8 KiB
Go
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
|
|
}
|