feat(tracker): spectrum analyzer

Closes #67
This commit is contained in:
5684185+vsariola@users.noreply.github.com
2025-12-29 23:57:08 +02:00
parent 4d09e04a49
commit 3a7010f897
16 changed files with 977 additions and 211 deletions

View File

@ -1,16 +1,10 @@
package gioui
import (
"image"
"image/color"
"math"
"strconv"
"gioui.org/f32"
"gioui.org/io/event"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"github.com/vsariola/sointu/tracker"
)
@ -21,30 +15,19 @@ type (
wrapBtn *Clickable
lengthInBeatsNumber *NumericUpDownState
triggerChannelNumber *NumericUpDownState
xScale int
xOffset float32
yScale float64
dragging bool
dragId pointer.ID
dragStartPoint f32.Point
}
OscilloscopeStyle struct {
CurveColors [2]color.NRGBA `yaml:",flow"`
LimitColor color.NRGBA `yaml:",flow"`
CursorColor color.NRGBA `yaml:",flow"`
plot *Plot
}
Oscilloscope struct {
Theme *Theme
Model *tracker.ScopeModel
State *OscilloscopeState
Style *OscilloscopeStyle
}
)
func NewOscilloscope(model *tracker.Model) *OscilloscopeState {
return &OscilloscopeState{
plot: NewPlot(plotRange{0, 1}, plotRange{-1, 1}, 0),
onceBtn: new(Clickable),
wrapBtn: new(Clickable),
lengthInBeatsNumber: NewNumericUpDownState(),
@ -57,11 +40,11 @@ func Scope(th *Theme, m *tracker.ScopeModel, st *OscilloscopeState) Oscilloscope
Theme: th,
Model: m,
State: st,
Style: &th.Oscilloscope,
}
}
func (s *Oscilloscope) Layout(gtx C) D {
t := TrackerFromContext(gtx)
leftSpacer := layout.Spacer{Width: unit.Dp(6), Height: unit.Dp(24)}.Layout
rightSpacer := layout.Spacer{Width: unit.Dp(6)}.Layout
@ -72,7 +55,54 @@ func (s *Oscilloscope) Layout(gtx C) D {
wrapBtn := ToggleBtn(s.Model.Wrap(), s.Theme, s.State.wrapBtn, "Wrap", "Wrap buffer when full")
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Flexed(1, s.layoutWave),
layout.Flexed(1, func(gtx C) D {
w := s.Model.Waveform()
cx := float32(w.Cursor) / float32(len(w.Buffer))
data := func(chn int, xr plotRange) (yr plotRange, ok bool) {
x1 := max(int(xr.a*float32(len(w.Buffer))), 0)
x2 := min(int(xr.b*float32(len(w.Buffer))), len(w.Buffer)-1)
if x1 > x2 {
return plotRange{}, false
}
y1 := float32(math.Inf(-1))
y2 := float32(math.Inf(+1))
for i := x1; i <= x2; i++ {
sample := w.Buffer[i][chn]
y1 = max(y1, sample)
y2 = min(y2, sample)
}
return plotRange{-y1, -y2}, true
}
rpb := max(t.Model.RowsPerBeat().Value(), 1)
xticks := func(r plotRange, count int, yield func(pos float32, label string)) {
l := s.Model.LengthInBeats().Value() * rpb
a := max(int(math.Ceil(float64(r.a*float32(l)))), 0)
b := min(int(math.Floor(float64(r.b*float32(l)))), l)
step := 1
n := rpb
for (b-a+1)/step > count {
step *= n
n = 2
}
a = (a / step) * step
for i := a; i <= b; i += step {
if i%rpb == 0 {
beat := i / rpb
yield(float32(i)/float32(l), strconv.Itoa(beat))
} else {
yield(float32(i)/float32(l), "")
}
}
}
yticks := func(r plotRange, count int, yield func(pos float32, label string)) {
yield(-1, "")
yield(1, "")
}
return s.State.plot.Layout(gtx, data, xticks, yticks, cx, 2)
}),
layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(leftSpacer),
@ -95,126 +125,3 @@ func (s *Oscilloscope) Layout(gtx C) D {
}),
)
}
func (s *Oscilloscope) layoutWave(gtx C) D {
s.State.update(gtx, s.Model.Waveform())
if gtx.Constraints.Max.X == 0 || gtx.Constraints.Max.Y == 0 {
return D{}
}
defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Max}).Push(gtx.Ops).Pop()
wave := s.Model.Waveform()
event.Op(gtx.Ops, s.State)
paint.ColorOp{Color: s.Style.CursorColor}.Add(gtx.Ops)
cursorX := int(s.State.sampleToPx(gtx, float32(wave.Cursor), wave))
fillRect(gtx, clip.Rect{Min: image.Pt(cursorX, 0), Max: image.Pt(cursorX+1, gtx.Constraints.Max.Y)})
paint.ColorOp{Color: s.Style.LimitColor}.Add(gtx.Ops)
minusOneY := int(s.State.ampToY(gtx, -1))
fillRect(gtx, clip.Rect{Min: image.Pt(0, minusOneY), Max: image.Pt(gtx.Constraints.Max.X, minusOneY+1)})
plusOneY := int(s.State.ampToY(gtx, 1))
fillRect(gtx, clip.Rect{Min: image.Pt(0, plusOneY), Max: image.Pt(gtx.Constraints.Max.X, plusOneY+1)})
leftX := int(s.State.sampleToPx(gtx, 0, wave))
fillRect(gtx, clip.Rect{Min: image.Pt(leftX, 0), Max: image.Pt(leftX+1, gtx.Constraints.Max.Y)})
rightX := int(s.State.sampleToPx(gtx, float32(len(wave.Buffer)-1), wave))
fillRect(gtx, clip.Rect{Min: image.Pt(rightX, 0), Max: image.Pt(rightX+1, gtx.Constraints.Max.Y)})
for chn := range 2 {
paint.ColorOp{Color: s.Style.CurveColors[chn]}.Add(gtx.Ops)
for px := range gtx.Constraints.Max.X {
// left and right is the sample range covered by the pixel
left := int(s.State.pxToSample(gtx, float32(px)-0.5, wave))
right := int(s.State.pxToSample(gtx, float32(px)+0.5, wave))
if right < 0 || left >= len(wave.Buffer) {
continue
}
right = min(right, len(wave.Buffer)-1)
left = max(left, 0)
// smin and smax are the smallest and largest sample values in the pixel range
smax := float32(math.Inf(-1))
smin := float32(math.Inf(1))
for x := left; x <= right; x++ {
smax = max(smax, wave.Buffer[x][chn])
smin = min(smin, wave.Buffer[x][chn])
}
// y1 and y2 are the pixel range covered by the sample value
y1 := min(max(int(s.State.ampToY(gtx, smax)+0.5), 0), gtx.Constraints.Max.Y-1)
y2 := min(max(int(s.State.ampToY(gtx, smin)+0.5), 0), gtx.Constraints.Max.Y-1)
fillRect(gtx, clip.Rect{Min: image.Pt(px, y1), Max: image.Pt(px+1, y2+1)})
}
}
return D{Size: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y)}
}
func fillRect(gtx C, rect clip.Rect) {
stack := rect.Push(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
stack.Pop()
}
func (o *OscilloscopeState) update(gtx C, wave tracker.RingBuffer[[2]float32]) {
for {
ev, ok := gtx.Event(pointer.Filter{
Target: o,
Kinds: pointer.Scroll | pointer.Press | pointer.Drag | pointer.Release | pointer.Cancel,
ScrollY: pointer.ScrollRange{Min: -1e6, Max: 1e6},
})
if !ok {
break
}
if e, ok := ev.(pointer.Event); ok {
switch e.Kind {
case pointer.Scroll:
s1 := o.pxToSample(gtx, e.Position.X, wave)
o.xScale += min(max(-1, int(e.Scroll.Y)), 1)
s2 := o.pxToSample(gtx, e.Position.X, wave)
o.xOffset -= s1 - s2
case pointer.Press:
if e.Buttons&pointer.ButtonSecondary != 0 {
o.xOffset = 0
o.xScale = 0
o.yScale = 0
}
if e.Buttons&pointer.ButtonPrimary != 0 {
o.dragging = true
o.dragId = e.PointerID
o.dragStartPoint = e.Position
}
case pointer.Drag:
if e.Buttons&pointer.ButtonPrimary != 0 && o.dragging && e.PointerID == o.dragId {
deltaX := o.pxToSample(gtx, e.Position.X, wave) - o.pxToSample(gtx, o.dragStartPoint.X, wave)
o.xOffset += deltaX
num := o.yToAmp(gtx, e.Position.Y)
den := o.yToAmp(gtx, o.dragStartPoint.Y)
if l := math.Abs(float64(num / den)); l > 1e-3 && l < 1e3 {
o.yScale += math.Log(l)
o.yScale = min(max(o.yScale, -1e3), 1e3)
}
o.dragStartPoint = e.Position
}
case pointer.Release | pointer.Cancel:
o.dragging = false
}
}
}
}
func (o *OscilloscopeState) scaleFactor() float32 {
return float32(math.Pow(1.1, float64(o.xScale)))
}
func (s *OscilloscopeState) pxToSample(gtx C, px float32, wave tracker.RingBuffer[[2]float32]) float32 {
return px*s.scaleFactor()*float32(len(wave.Buffer))/float32(gtx.Constraints.Max.X) - s.xOffset
}
func (s *OscilloscopeState) sampleToPx(gtx C, sample float32, wave tracker.RingBuffer[[2]float32]) float32 {
return (sample + s.xOffset) * float32(gtx.Constraints.Max.X) / float32(len(wave.Buffer)) / s.scaleFactor()
}
func (s *OscilloscopeState) ampToY(gtx C, amp float32) float32 {
scale := float32(math.Exp(s.yScale))
return (1 - amp*scale) / 2 * float32(gtx.Constraints.Max.Y-1)
}
func (s *OscilloscopeState) yToAmp(gtx C, y float32) float32 {
scale := float32(math.Exp(s.yScale))
return (1 - y/float32(gtx.Constraints.Max.Y-1)*2) / scale
}

186
tracker/gioui/plot.go Normal file
View File

@ -0,0 +1,186 @@
package gioui
import (
"image"
"image/color"
"math"
"gioui.org/f32"
"gioui.org/io/event"
"gioui.org/io/pointer"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
)
type (
Plot struct {
origXlim, origYlim plotRange
fixedYLevel float32
xScale, yScale float32
xOffset float32
dragging bool
dragId pointer.ID
dragStartPoint f32.Point
}
PlotStyle struct {
CurveColors [3]color.NRGBA `yaml:",flow"`
LimitColor color.NRGBA `yaml:",flow"`
CursorColor color.NRGBA `yaml:",flow"`
Ticks LabelStyle
DpPerTick unit.Dp
}
PlotDataFunc func(chn int, xr plotRange) (yr plotRange, ok bool)
PlotTickFunc func(r plotRange, num int, yield func(pos float32, label string))
plotRange struct{ a, b float32 }
plotRel float32
plotPx int
plotLogScale float32
)
func NewPlot(xlim, ylim plotRange, fixedYLevel float32) *Plot {
return &Plot{
origXlim: xlim,
origYlim: ylim,
fixedYLevel: fixedYLevel,
}
}
func (p *Plot) Layout(gtx C, data PlotDataFunc, xticks, yticks PlotTickFunc, cursornx float32, numchns int) D {
p.update(gtx)
t := TrackerFromContext(gtx)
style := t.Theme.Plot
s := gtx.Constraints.Max
if s.X <= 1 || s.Y <= 1 {
return D{}
}
defer clip.Rect(image.Rectangle{Max: s}).Push(gtx.Ops).Pop()
event.Op(gtx.Ops, p)
xlim := p.xlim()
ylim := p.ylim()
// draw tick marks
numxticks := s.X / gtx.Dp(style.DpPerTick)
xticks(xlim, numxticks, func(x float32, txt string) {
paint.ColorOp{Color: style.LimitColor}.Add(gtx.Ops)
sx := plotPx(s.X).toScreen(xlim.toRelative(x))
fillRect(gtx, clip.Rect{Min: image.Pt(sx, 0), Max: image.Pt(sx+1, s.Y)})
defer op.Offset(image.Pt(sx, gtx.Dp(2))).Push(gtx.Ops).Pop()
Label(t.Theme, &t.Theme.Plot.Ticks, txt).Layout(gtx)
})
numyticks := s.Y / gtx.Dp(style.DpPerTick)
yticks(ylim, numyticks, func(y float32, txt string) {
paint.ColorOp{Color: style.LimitColor}.Add(gtx.Ops)
sy := plotPx(s.Y).toScreen(ylim.toRelative(y))
fillRect(gtx, clip.Rect{Min: image.Pt(0, sy), Max: image.Pt(s.X, sy+1)})
defer op.Offset(image.Pt(gtx.Dp(2), sy)).Push(gtx.Ops).Pop()
Label(t.Theme, &t.Theme.Plot.Ticks, txt).Layout(gtx)
})
// draw cursor
if cursornx == cursornx { // check for NaN
paint.ColorOp{Color: style.CursorColor}.Add(gtx.Ops)
csx := plotPx(s.X).toScreen(xlim.toRelative(cursornx))
fillRect(gtx, clip.Rect{Min: image.Pt(csx, 0), Max: image.Pt(csx+1, s.Y)})
}
// draw curves
for chn := range numchns {
paint.ColorOp{Color: style.CurveColors[chn]}.Add(gtx.Ops)
right := xlim.fromRelative(plotPx(s.X).fromScreen(0))
for sx := range s.X {
// left and right is the sample range covered by the pixel
left := right
right = xlim.fromRelative(plotPx(s.X).fromScreen(sx + 1))
yr, ok := data(chn, plotRange{left, right})
if !ok {
continue
}
y1 := plotPx(s.Y).toScreen(ylim.toRelative(yr.a))
y2 := plotPx(s.Y).toScreen(ylim.toRelative(yr.b))
fillRect(gtx, clip.Rect{Min: image.Pt(sx, min(y1, y2)), Max: image.Pt(sx+1, max(y1, y2)+1)})
}
}
return D{Size: s}
}
func (r plotRange) toRelative(f float32) plotRel { return plotRel((f - r.a) / (r.b - r.a)) }
func (r plotRange) fromRelative(pr plotRel) float32 { return float32(pr)*(r.b-r.a) + r.a }
func (r plotRange) offset(o float32) plotRange { return plotRange{r.a + o, r.b + o} }
func (r plotRange) scale(logScale float32) plotRange {
s := float32(math.Exp(float64(logScale)))
return plotRange{r.a * s, r.b * s}
}
func (s plotPx) toScreen(pr plotRel) int { return int(float32(pr)*float32(s-1) + 0.5) }
func (s plotPx) fromScreen(px int) plotRel { return plotRel(float32(px) / float32(s-1)) }
func (s plotPx) fromScreenF32(px float32) plotRel { return plotRel(px / float32(s-1)) }
func (o *Plot) xlim() plotRange { return o.origXlim.scale(o.xScale).offset(o.xOffset) }
func (o *Plot) ylim() plotRange {
return o.origYlim.offset(-o.fixedYLevel).scale(o.yScale).offset(o.fixedYLevel)
}
func fillRect(gtx C, rect clip.Rect) {
stack := rect.Push(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
stack.Pop()
}
func (o *Plot) update(gtx C) {
s := gtx.Constraints.Max
for {
ev, ok := gtx.Event(pointer.Filter{
Target: o,
Kinds: pointer.Scroll | pointer.Press | pointer.Drag | pointer.Release | pointer.Cancel,
ScrollY: pointer.ScrollRange{Min: -1e6, Max: 1e6},
})
if !ok {
break
}
if e, ok := ev.(pointer.Event); ok {
switch e.Kind {
case pointer.Scroll:
x1 := o.xlim().fromRelative(plotPx(s.X).fromScreenF32(e.Position.X))
o.xScale += float32(min(max(-1, int(e.Scroll.Y)), 1)) * 0.1
x2 := o.xlim().fromRelative(plotPx(s.X).fromScreenF32(e.Position.X))
o.xOffset += x1 - x2
case pointer.Press:
if e.Buttons&pointer.ButtonSecondary != 0 {
o.xOffset = 0
o.xScale = 0
o.yScale = 0
}
if e.Buttons&pointer.ButtonPrimary != 0 {
o.dragging = true
o.dragId = e.PointerID
o.dragStartPoint = e.Position
}
case pointer.Drag:
if e.Buttons&pointer.ButtonPrimary != 0 && o.dragging && e.PointerID == o.dragId {
x1 := o.xlim().fromRelative(plotPx(s.X).fromScreenF32(o.dragStartPoint.X))
x2 := o.xlim().fromRelative(plotPx(s.X).fromScreenF32(e.Position.X))
o.xOffset += x1 - x2
num := o.ylim().fromRelative(plotPx(s.Y).fromScreenF32(e.Position.Y))
den := o.ylim().fromRelative(plotPx(s.Y).fromScreenF32(o.dragStartPoint.Y))
num -= o.fixedYLevel
den -= o.fixedYLevel
if l := math.Abs(float64(num / den)); l > 1e-3 && l < 1e3 {
o.yScale -= float32(math.Log(l))
o.yScale = min(max(o.yScale, -1e3), 1e3)
}
o.dragStartPoint = e.Position
}
case pointer.Release | pointer.Cancel:
o.dragging = false
}
}
}
}

View File

@ -8,7 +8,10 @@ import (
"strconv"
"strings"
"gioui.org/f32"
"gioui.org/gesture"
"gioui.org/io/event"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
@ -26,6 +29,7 @@ type SongPanel struct {
LoudnessExpander *Expander
PeakExpander *Expander
CPUExpander *Expander
SpectrumExpander *Expander
WeightingTypeBtn *Clickable
OversamplingBtn *Clickable
@ -37,7 +41,14 @@ type SongPanel struct {
Step *NumericUpDownState
SongLength *NumericUpDownState
Scope *OscilloscopeState
List *layout.List
ScrollBar *ScrollBar
Scope *OscilloscopeState
ScopeScaleBar *ScaleBar
SpectrumState *SpectrumState
SpectrumScaleBar *ScaleBar
MenuBar *MenuBar
PlayBar *PlayBar
@ -63,6 +74,14 @@ func NewSongPanel(tr *Tracker) *SongPanel {
LoudnessExpander: &Expander{},
PeakExpander: &Expander{},
CPUExpander: &Expander{},
SpectrumExpander: &Expander{},
List: &layout.List{Axis: layout.Vertical},
ScrollBar: &ScrollBar{Axis: layout.Vertical},
SpectrumState: NewSpectrumState(),
SpectrumScaleBar: &ScaleBar{Axis: layout.Vertical, BarSize: 10, Size: 300},
ScopeScaleBar: &ScaleBar{Axis: layout.Vertical, BarSize: 10, Size: 300},
}
return ret
}
@ -152,8 +171,9 @@ func (t *SongPanel) layoutSongOptions(gtx C) D {
synthBtn := Btn(tr.Theme, &tr.Theme.Button.Text, t.SynthBtn, tr.Model.SyntherName(), "")
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
listItem := func(gtx C, index int) D {
switch index {
case 0:
return t.SongSettingsExpander.Layout(gtx, tr.Theme, "Song",
func(gtx C) D {
return Label(tr.Theme, &tr.Theme.SongPanel.RowHeader, strconv.Itoa(tr.BPM().Value())+" BPM").Layout(gtx)
@ -182,8 +202,7 @@ func (t *SongPanel) layoutSongOptions(gtx C) D {
}),
)
})
}),
layout.Rigid(func(gtx C) D {
case 1:
return t.CPUExpander.Layout(gtx, tr.Theme, "CPU", cpuSmallLabel,
func(gtx C) D {
return layout.Flex{Axis: layout.Vertical, Alignment: layout.End}.Layout(gtx,
@ -192,8 +211,7 @@ func (t *SongPanel) layoutSongOptions(gtx C) D {
)
},
)
}),
layout.Rigid(func(gtx C) D {
case 2:
return t.LoudnessExpander.Layout(gtx, tr.Theme, "Loudness",
func(gtx C) D {
loudness := tr.Model.DetectorResult().Loudness[tracker.LoudnessShortTerm]
@ -223,8 +241,7 @@ func (t *SongPanel) layoutSongOptions(gtx C) D {
)
},
)
}),
layout.Rigid(func(gtx C) D {
case 3:
return t.PeakExpander.Layout(gtx, tr.Theme, "Peaks",
func(gtx C) D {
maxPeak := max(tr.Model.DetectorResult().Peaks[tracker.PeakShortTerm][0], tr.Model.DetectorResult().Peaks[tracker.PeakShortTerm][1])
@ -252,13 +269,28 @@ func (t *SongPanel) layoutSongOptions(gtx C) D {
)
},
)
}),
layout.Flexed(1, func(gtx C) D {
case 4:
scope := Scope(tr.Theme, tr.Model.SignalAnalyzer(), t.Scope)
return t.ScopeExpander.Layout(gtx, tr.Theme, "Oscilloscope", func(gtx C) D { return D{} }, scope.Layout)
}),
layout.Rigid(Label(tr.Theme, &tr.Theme.SongPanel.Version, version.VersionOrHash).Layout),
)
scopeScaleBar := func(gtx C) D {
return t.ScopeScaleBar.Layout(gtx, scope.Layout)
}
return t.ScopeExpander.Layout(gtx, tr.Theme, "Oscilloscope", func(gtx C) D { return D{} }, scopeScaleBar)
case 5:
spectrumScaleBar := func(gtx C) D {
return t.SpectrumScaleBar.Layout(gtx, t.SpectrumState.Layout)
}
return t.SpectrumExpander.Layout(gtx, tr.Theme, "Spectrum", func(gtx C) D { return D{} }, spectrumScaleBar)
case 6:
return Label(tr.Theme, &tr.Theme.SongPanel.Version, version.VersionOrHash).Layout(gtx)
default:
return D{}
}
}
gtx.Constraints.Min = gtx.Constraints.Max
dims := t.List.Layout(gtx, 7, listItem)
t.ScrollBar.Layout(gtx, &tr.Theme.SongPanel.ScrollBar, 7, &t.List.Position)
tr.SpecAnEnabled().SetValue(t.SpectrumExpander.Expanded)
return dims
}
func dbLabel(th *Theme, value tracker.Decibel) LabelWidget {
@ -282,6 +314,87 @@ func layoutSongOptionRow(gtx C, th *Theme, label string, widget layout.Widget) D
)
}
type ScaleBar struct {
Size, BarSize unit.Dp
Axis layout.Axis
drag bool
dragID pointer.ID
dragStart f32.Point
}
func (s *ScaleBar) Layout(gtx C, w layout.Widget) D {
s.Update(gtx)
pxBar := gtx.Dp(s.BarSize)
pxTot := gtx.Dp(s.Size) + pxBar
var rect image.Rectangle
var size image.Point
if s.Axis == layout.Horizontal {
pxTot = min(max(gtx.Constraints.Min.X, pxTot), gtx.Constraints.Max.X)
px := pxTot - pxBar
rect = image.Rect(px, 0, pxTot, gtx.Constraints.Max.Y)
size = image.Pt(pxTot, gtx.Constraints.Max.Y)
gtx.Constraints.Max.X = px
gtx.Constraints.Min.X = min(gtx.Constraints.Min.X, px)
} else {
pxTot = min(max(gtx.Constraints.Min.Y, pxTot), gtx.Constraints.Max.Y)
px := pxTot - pxBar
rect = image.Rect(0, px, gtx.Constraints.Max.X, pxTot)
size = image.Pt(gtx.Constraints.Max.X, pxTot)
gtx.Constraints.Max.Y = px
gtx.Constraints.Min.Y = min(gtx.Constraints.Min.Y, px)
}
area := clip.Rect(rect).Push(gtx.Ops)
event.Op(gtx.Ops, s)
if s.Axis == layout.Horizontal {
pointer.CursorColResize.Add(gtx.Ops)
} else {
pointer.CursorRowResize.Add(gtx.Ops)
}
area.Pop()
w(gtx)
return D{Size: size}
}
func (s *ScaleBar) Update(gtx C) {
for {
ev, ok := gtx.Event(pointer.Filter{
Target: s,
Kinds: pointer.Press | pointer.Drag | pointer.Release,
})
if !ok {
break
}
e, ok := ev.(pointer.Event)
if !ok {
continue
}
switch e.Kind {
case pointer.Press:
if s.drag {
break
}
s.dragID = e.PointerID
s.dragStart = e.Position
s.drag = true
case pointer.Drag:
if s.dragID != e.PointerID {
break
}
if s.Axis == layout.Horizontal {
s.Size += gtx.Metric.PxToDp(int(e.Position.X - s.dragStart.X))
} else {
s.Size += gtx.Metric.PxToDp(int(e.Position.Y - s.dragStart.Y))
}
s.Size = max(s.Size, unit.Dp(50))
s.dragStart = e.Position
case pointer.Release, pointer.Cancel:
s.drag = false
}
}
}
type Expander struct {
Expanded bool
click gesture.Click

View File

@ -0,0 +1,217 @@
package gioui
import (
"fmt"
"math"
"strconv"
"gioui.org/layout"
"gioui.org/unit"
"github.com/vsariola/sointu/tracker"
)
type (
SpectrumState struct {
resolutionNumber *NumericUpDownState
speed *NumericUpDownState
chnModeBtn *Clickable
plot *Plot
}
)
const (
SpectrumDbMin = -60
SpectrumDbMax = 12
)
func NewSpectrumState() *SpectrumState {
return &SpectrumState{
plot: NewPlot(plotRange{-3.8, 0}, plotRange{SpectrumDbMax, SpectrumDbMin}, SpectrumDbMin),
resolutionNumber: NewNumericUpDownState(),
speed: NewNumericUpDownState(),
chnModeBtn: new(Clickable),
}
}
func (s *SpectrumState) Layout(gtx C) D {
s.Update(gtx)
t := TrackerFromContext(gtx)
leftSpacer := layout.Spacer{Width: unit.Dp(6), Height: unit.Dp(36)}.Layout
rightSpacer := layout.Spacer{Width: unit.Dp(6)}.Layout
var chnModeTxt string = "???"
switch tracker.SpecChnMode(t.Model.SpecAnChannelsInt().Value()) {
case tracker.SpecChnModeSum:
chnModeTxt = "Sum"
case tracker.SpecChnModeSeparate:
chnModeTxt = "Separate"
}
resolution := NumUpDown(t.Model.SpecAnResolution(), t.Theme, s.resolutionNumber, "Resolution")
chnModeBtn := Btn(t.Theme, &t.Theme.Button.Text, s.chnModeBtn, chnModeTxt, "Channel mode")
speed := NumUpDown(t.Model.SpecAnSpeed(), t.Theme, s.speed, "Speed")
numchns := 0
speclen := len(t.Model.Spectrum()[0])
if speclen > 0 {
numchns = 1
if len(t.Model.Spectrum()[1]) == speclen {
numchns = 2
}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Flexed(1, func(gtx C) D {
biquad, biquadok := t.Model.BiquadCoeffs()
data := func(chn int, xr plotRange) (yr plotRange, ok bool) {
if chn == 2 {
if xr.a >= 0 {
return plotRange{}, false
}
ya := math.Log10(float64(biquad.Gain(float32(math.Pi*math.Pow(10, float64(xr.a)))))) * 20
yb := math.Log10(float64(biquad.Gain(float32(math.Pi*math.Pow(10, float64(xr.b)))))) * 20
return plotRange{float32(ya), float32(yb)}, true
}
if chn >= numchns {
return plotRange{}, false
}
xr.a = float32(math.Pow(10, float64(xr.a)))
xr.b = float32(math.Pow(10, float64(xr.b)))
w1, f1 := math.Modf(float64(xr.a)*float64(speclen) - 1) // -1 cause we don't have the DC bin there
w2, f2 := math.Modf(float64(xr.b)*float64(speclen) - 1) // -1 cause we don't have the DC bin there
x1 := max(int(w1), 0)
x2 := min(int(w2), speclen-1)
if x1 > x2 {
return plotRange{}, false
}
y1 := float32(math.Inf(-1))
y2 := float32(math.Inf(+1))
switch {
case x2 <= x1+1 && x2 < speclen-1: // perform smoothstep interpolation when we are overlapping only a few bins
l := t.Model.Spectrum()[chn][x1]
r := t.Model.Spectrum()[chn][x1+1]
y1 = smoothInterpolate(l, r, float32(f1))
l = t.Model.Spectrum()[chn][x2]
r = t.Model.Spectrum()[chn][x2+1]
y2 = smoothInterpolate(l, r, float32(f2))
y1, y2 = max(y1, y2), min(y1, y2)
default:
for i := x1; i <= x2; i++ {
sample := t.Model.Spectrum()[chn][i]
y1 = max(y1, sample)
y2 = min(y2, sample)
}
}
y1 = softplus((y1-SpectrumDbMin)/5)*5 + SpectrumDbMin // we "squash" the low volumes so the -Inf dB becomes -SpectrumDbMin
y2 = softplus((y2-SpectrumDbMin)/5)*5 + SpectrumDbMin
return plotRange{y1, y2}, true
}
xticks := func(r plotRange, count int, yield func(pos float32, label string)) {
type pair struct {
freq float64
label string
}
const offset = 0.343408593803857 // log10(22050/10000)
const startdiv = 3 * (1 << 8)
step := nextPowerOfTwo(int(float64(r.b-r.a)*startdiv/float64(count)) + 1)
start := int(math.Floor(float64(r.a+offset) * startdiv / float64(step)))
end := int(math.Ceil(float64(r.b+offset) * startdiv / float64(step)))
for i := start; i <= end; i++ {
lognormfreq := float32(i*step)/startdiv - offset
freq := math.Pow(10, float64(lognormfreq)) * 22050
df := freq * math.Log(10) * float64(step) / startdiv // this is roughly the difference in Hz between the ticks currently
rounding := int(math.Floor(math.Log10(df)))
r := math.Pow(10, float64(rounding))
freq = math.Round(freq/r) * r
tickpos := float32(math.Log10(freq / 22050))
if rounding >= 3 {
yield(tickpos, fmt.Sprintf("%.0f kHz", freq/1000))
} else {
yield(tickpos, fmt.Sprintf("%s Hz", strconv.FormatFloat(freq, 'f', -rounding, 64)))
}
}
}
yticks := func(r plotRange, count int, yield func(pos float32, label string)) {
step := 3
var start, end int
for {
start = int(math.Ceil(float64(r.b) / float64(step)))
end = int(math.Floor(float64(r.a) / float64(step)))
if end-start+1 <= count*4 { // we use 4x density for the y-lines in the spectrum
break
}
step *= 2
}
for i := start; i <= end; i++ {
yield(float32(i*step), strconv.Itoa(i*step))
}
}
n := numchns
if biquadok {
n = 3
}
return s.plot.Layout(gtx, data, xticks, yticks, float32(math.NaN()), n)
}),
layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(leftSpacer),
layout.Rigid(Label(t.Theme, &t.Theme.SongPanel.RowHeader, "Resolution").Layout),
layout.Flexed(1, func(gtx C) D { return D{Size: gtx.Constraints.Min} }),
layout.Rigid(resolution.Layout),
layout.Rigid(rightSpacer),
)
}),
layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(leftSpacer),
layout.Rigid(Label(t.Theme, &t.Theme.SongPanel.RowHeader, "Speed").Layout),
layout.Flexed(1, func(gtx C) D { return D{Size: gtx.Constraints.Min} }),
layout.Rigid(speed.Layout),
layout.Rigid(rightSpacer),
)
}),
layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(leftSpacer),
layout.Rigid(Label(t.Theme, &t.Theme.SongPanel.RowHeader, "Channels").Layout),
layout.Flexed(1, func(gtx C) D { return D{Size: gtx.Constraints.Min} }),
layout.Rigid(chnModeBtn.Layout),
layout.Rigid(rightSpacer),
)
}),
)
}
func softplus(f float32) float32 {
return float32(math.Log(1 + math.Exp(float64(f))))
}
func smoothInterpolate(a, b float32, t float32) float32 {
t = t * t * (3 - 2*t)
return (1-t)*a + t*b
}
func nextPowerOfTwo(v int) int {
if v <= 0 {
return 1
}
v--
v |= v >> 1
v |= v >> 2
v |= v >> 4
v |= v >> 8
v |= v >> 16
v |= v >> 32
v++
return v
}
func (s *SpectrumState) Update(gtx C) {
t := TrackerFromContext(gtx)
for s.chnModeBtn.Clicked(gtx) {
t.Model.SpecAnChannelsInt().SetValue((t.SpecAnChannelsInt().Value() + 1) % int(tracker.NumSpecChnModes))
}
s.resolutionNumber.Update(gtx, t.Model.SpecAnResolution())
s.speed.Update(gtx, t.Model.SpecAnSpeed())
}

View File

@ -32,7 +32,7 @@ type Theme struct {
Emphasis IconButtonStyle
Error IconButtonStyle
}
Oscilloscope OscilloscopeStyle
Plot PlotStyle
NumericUpDown NumericUpDownStyle
SongPanel struct {
RowHeader LabelStyle
@ -41,6 +41,7 @@ type Theme struct {
Version LabelStyle
ErrorColor color.NRGBA
Bg color.NRGBA
ScrollBar ScrollBarStyle
}
Alert AlertStyles
NoteEditor struct {

View File

@ -83,10 +83,12 @@ iconbutton:
color: *errorcolor
size: 24
inset: { top: 6, bottom: 6, left: 6, right: 6 }
oscilloscope:
curvecolors: [*primarycolor, *secondarycolor]
plot:
curvecolors: [*primarycolor, *secondarycolor,*disabled]
limitcolor: { r: 255, g: 255, b: 255, a: 8 }
cursorcolor: { r: 252, g: 186, b: 3, a: 255 }
ticks: { textsize: 12, color: *disabled, maxlines: 1}
dppertick: 50
numericupdown:
bgcolor: { r: 255, g: 255, b: 255, a: 3 }
textcolor: *fg
@ -111,6 +113,7 @@ songpanel:
version:
textsize: 12
color: *mediumemphasis
scrollbar: { width: 6, color: *scrollbarcolor }
alert:
error:
bg: *errorcolor