From 3a7010f89797ebd0aaeab6c7d73cc5af71501b00 Mon Sep 17 00:00:00 2001 From: "5684185+vsariola@users.noreply.github.com" <5684185+vsariola@users.noreply.github.com> Date: Mon, 29 Dec 2025 23:57:08 +0200 Subject: [PATCH] feat(tracker): spectrum analyzer Closes #67 --- CHANGELOG.md | 4 + cmd/sointu-track/main.go | 4 + cmd/sointu-vsti/main.go | 4 + patch.go | 19 +-- tracker/broker.go | 30 +++- tracker/detector.go | 41 +++--- tracker/gioui/oscilloscope.go | 197 +++++++------------------ tracker/gioui/plot.go | 186 +++++++++++++++++++++++ tracker/gioui/song_panel.go | 143 ++++++++++++++++-- tracker/gioui/specanalyzer.go | 217 +++++++++++++++++++++++++++ tracker/gioui/theme.go | 3 +- tracker/gioui/theme.yml | 7 +- tracker/int.go | 32 ++++ tracker/model.go | 24 ++- tracker/scopemodel.go | 10 +- tracker/spectrum.go | 267 ++++++++++++++++++++++++++++++++++ 16 files changed, 977 insertions(+), 211 deletions(-) create mode 100644 tracker/gioui/plot.go create mode 100644 tracker/gioui/specanalyzer.go create mode 100644 tracker/spectrum.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bbf2b5..5b090da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Added +- Spectrum analyzer showing the spectrum. When the user has a filter or belleq + unit selected, it's frequency response is plotted on top. ([#67][i67]) - belleq unit: a bell-shaped second-order filter for equalization. Belleq unit takes the center frequency, bandwidth (inverse of Q-factor) and gain (+-40 dB). Useful for boosting or reducing specific frequency ranges. Kudos to Reaby @@ -29,6 +31,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). did not, resulting it claiming errors in patches that worked once compiled. ### Changed +- The song panel can scroll if all the widgets don't fit into it - The provided MacOS executables are now arm64, which means the x86 native synths are not compiled in. @@ -352,6 +355,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). [0.1.0]: https://github.com/vsariola/sointu/compare/4klang-3.11...v0.1.0 [i61]: https://github.com/vsariola/sointu/issues/61 [i65]: https://github.com/vsariola/sointu/issues/65 +[i67]: https://github.com/vsariola/sointu/issues/67 [i68]: https://github.com/vsariola/sointu/issues/68 [i77]: https://github.com/vsariola/sointu/issues/77 [i91]: https://github.com/vsariola/sointu/issues/91 diff --git a/cmd/sointu-track/main.go b/cmd/sointu-track/main.go index 47cc635..75c1baa 100644 --- a/cmd/sointu-track/main.go +++ b/cmd/sointu-track/main.go @@ -52,7 +52,9 @@ func main() { model := tracker.NewModel(broker, cmd.Synthers, midiContext, recoveryFile) player := tracker.NewPlayer(broker, cmd.Synthers[0]) detector := tracker.NewDetector(broker) + specan := tracker.NewSpecAnalyzer(broker) go detector.Run() + go specan.Run() if a := flag.Args(); len(a) > 0 { f, err := os.Open(a[0]) @@ -72,7 +74,9 @@ func main() { trackerUi.Main() audioCloser.Close() tracker.TrySend(broker.CloseDetector, struct{}{}) + tracker.TrySend(broker.CloseSpecAn, struct{}{}) tracker.TimeoutReceive(broker.FinishedDetector, 3*time.Second) + tracker.TimeoutReceive(broker.FinishedSpecAn, 3*time.Second) if *cpuprofile != "" { pprof.StopCPUProfile() f.Close() diff --git a/cmd/sointu-vsti/main.go b/cmd/sointu-vsti/main.go index 28a3e3b..ba3336f 100644 --- a/cmd/sointu-vsti/main.go +++ b/cmd/sointu-vsti/main.go @@ -49,7 +49,9 @@ func init() { model := tracker.NewModel(broker, cmd.Synthers, cmd.NewMidiContext(broker), recoveryFile) player := tracker.NewPlayer(broker, cmd.Synthers[0]) detector := tracker.NewDetector(broker) + specan := tracker.NewSpecAnalyzer(broker) go detector.Run() + go specan.Run() t := gioui.NewTracker(model) model.InstrEnlarged().SetValue(true) @@ -112,8 +114,10 @@ func init() { CloseFunc: func() { tracker.TrySend(broker.CloseDetector, struct{}{}) tracker.TrySend(broker.CloseGUI, struct{}{}) + tracker.TrySend(broker.CloseSpecAn, struct{}{}) tracker.TimeoutReceive(broker.FinishedDetector, 3*time.Second) tracker.TimeoutReceive(broker.FinishedGUI, 3*time.Second) + tracker.TimeoutReceive(broker.FinishedSpecAn, 3*time.Second) }, GetChunkFunc: func(isPreset bool) []byte { retChn := make(chan []byte) diff --git a/patch.go b/patch.go index 0d1f72c..370cdda 100644 --- a/patch.go +++ b/patch.go @@ -251,23 +251,12 @@ func arrDispFunc(arr []string) UnitParameterDisplayFunc { } func filterFrequencyDispFunc(v int) (string, string) { - // Matlab was used to find the frequency for the singularity when r = 0: - // % p is the frequency parameter squared, p = freq * freq - // % We assume the singular case r = 0. - // syms p z s T - // A = [1 p;-p 1-p*p]; % discrete state-space matrix x(k+1)=A*x(k) + ... - // pol = det(z*eye(2)-A) % characteristic discrete polynomial - // spol = simplify(subs(pol,z,(1+s*T/2)/(1-s*T/2))) % Tustin approximation - // % where T = 1/(44100 Hz) is the sample period - // % spol is of the form N(s)/D(s), where N(s)=(-T^2*p^2*s^2+4*T^2*s^2+4*p^2) - // % We are interested in the roots i.e. when spol == 0 <=> N(s)==0 - // simplify(solve((-T^2*p^2*s^2+4*T^2*s^2+4*p^2)==0,s)) - // % Answer: s=±2*p/(T*(p^2-4)^(1/2)). For small p, this simplifies to: - // % s=±p*j/T. Thus, s=j*omega=j*2*pi*f => f=±p/(2*pi*T). - // So the singularity is when f = p / (2*pi*T) Hz. + // In https://www.musicdsp.org/en/latest/Filters/23-state-variable.html, + // they call it "cutoff" but it's actually the location of the resonance + // peak freq := float64(v) / 128 p := freq * freq - f := 44100 * p / math.Pi / 2 + f := math.Asin(p/2) / math.Pi * 44100 return strconv.FormatFloat(f, 'f', 0, 64), "Hz" } diff --git a/tracker/broker.go b/tracker/broker.go index c6d8f98..e764109 100644 --- a/tracker/broker.go +++ b/tracker/broker.go @@ -37,19 +37,23 @@ type ( ToPlayer chan any // TODO: consider using a sum type here, for a bit more type safety. See: https://www.jerf.org/iri/post/2917/ ToDetector chan MsgToDetector ToGUI chan any + ToSpecAn chan MsgToSpecAn CloseDetector chan struct{} CloseGUI chan struct{} + CloseSpecAn chan struct{} FinishedGUI chan struct{} FinishedDetector chan struct{} + FinishedSpecAn chan struct{} // mIDIEventsToGUI is true if all MIDI events should be sent to the GUI, // for inputting notes to tracks. If false, they should be sent to the // player instead. mIDIEventsToGUI atomic.Bool - bufferPool sync.Pool + bufferPool sync.Pool + spectrumPool sync.Pool } // MsgToModel is a message sent to the model. The most often sent data @@ -93,6 +97,12 @@ type ( Param int } + MsgToSpecAn struct { + SpecSettings SpecAnSettings + HasSettings bool + Data any + } + GUIMessageKind int ) @@ -108,11 +118,15 @@ func NewBroker() *Broker { ToModel: make(chan MsgToModel, 1024), ToDetector: make(chan MsgToDetector, 1024), ToGUI: make(chan any, 1024), + ToSpecAn: make(chan MsgToSpecAn, 1024), CloseDetector: make(chan struct{}, 1), CloseGUI: make(chan struct{}, 1), + CloseSpecAn: make(chan struct{}, 1), FinishedGUI: make(chan struct{}), FinishedDetector: make(chan struct{}), + FinishedSpecAn: make(chan struct{}), bufferPool: sync.Pool{New: func() any { return &sointu.AudioBuffer{} }}, + spectrumPool: sync.Pool{New: func() any { return &Spectrum{} }}, } } @@ -140,6 +154,20 @@ func (b *Broker) PutAudioBuffer(buf *sointu.AudioBuffer) { b.bufferPool.Put(buf) } +func (b *Broker) GetSpectrum() *Spectrum { + return b.spectrumPool.Get().(*Spectrum) +} + +func (b *Broker) PutSpectrum(s *Spectrum) { + if len((*s)[0]) > 0 { + (*s)[0] = (*s)[0][:0] + } + if len((*s)[1]) > 0 { + (*s)[1] = (*s)[1][:0] + } + b.spectrumPool.Put(s) +} + // TrySend is a helper function to send a value to a channel if it is not full. // It is guaranteed to be non-blocking. Return true if the value was sent, false // otherwise. diff --git a/tracker/detector.go b/tracker/detector.go index 0cb07f5..99d1abf 100644 --- a/tracker/detector.go +++ b/tracker/detector.go @@ -12,7 +12,7 @@ type ( broker *Broker loudnessDetector loudnessDetector peakDetector peakDetector - chunkHistory sointu.AudioBuffer + chunker chunker } WeightingType int @@ -62,6 +62,10 @@ type ( history [11]float32 tmp, tmp2 []float32 } + + chunker struct { + buffer sointu.AudioBuffer + } ) const ( @@ -132,26 +136,7 @@ func (s *Detector) handleMsg(msg MsgToDetector) { switch data := msg.Data.(type) { case *sointu.AudioBuffer: buf := *data - for { - var chunk sointu.AudioBuffer - if len(s.chunkHistory) > 0 && len(s.chunkHistory) < 4410 { - l := min(len(buf), 4410-len(s.chunkHistory)) - s.chunkHistory = append(s.chunkHistory, buf[:l]...) - if len(s.chunkHistory) < 4410 { - break - } - chunk = s.chunkHistory - buf = buf[l:] - } else { - if len(buf) >= 4410 { - chunk = buf[:4410] - buf = buf[4410:] - } else { - s.chunkHistory = s.chunkHistory[:0] - s.chunkHistory = append(s.chunkHistory, buf...) - break - } - } + s.chunker.Process(buf, 4410, 0, func(chunk sointu.AudioBuffer) { TrySend(s.broker.ToModel, MsgToModel{ HasDetectorResult: true, DetectorResult: DetectorResult{ @@ -159,7 +144,8 @@ func (s *Detector) handleMsg(msg MsgToDetector) { Peaks: s.peakDetector.update(chunk), }, }) - } + }) + s.broker.PutAudioBuffer(data) } } @@ -432,3 +418,14 @@ func (d *peakDetector) reset() { d.maxPower[chn] = 0 } } + +func (c *chunker) Process(input sointu.AudioBuffer, windowLen, overlap int, cb func(sointu.AudioBuffer)) { + c.buffer = append(c.buffer, input...) + b := c.buffer + for len(b) >= windowLen { + cb(b[:windowLen]) + b = b[windowLen-overlap:] + } + copy(c.buffer, b) + c.buffer = c.buffer[:len(b)] +} diff --git a/tracker/gioui/oscilloscope.go b/tracker/gioui/oscilloscope.go index 31eb195..0f3917d 100644 --- a/tracker/gioui/oscilloscope.go +++ b/tracker/gioui/oscilloscope.go @@ -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 -} diff --git a/tracker/gioui/plot.go b/tracker/gioui/plot.go new file mode 100644 index 0000000..dbcda5c --- /dev/null +++ b/tracker/gioui/plot.go @@ -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 + } + } + } +} diff --git a/tracker/gioui/song_panel.go b/tracker/gioui/song_panel.go index 04a1383..a32603e 100644 --- a/tracker/gioui/song_panel.go +++ b/tracker/gioui/song_panel.go @@ -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 diff --git a/tracker/gioui/specanalyzer.go b/tracker/gioui/specanalyzer.go new file mode 100644 index 0000000..5f370dd --- /dev/null +++ b/tracker/gioui/specanalyzer.go @@ -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()) +} diff --git a/tracker/gioui/theme.go b/tracker/gioui/theme.go index 04f64d0..141c448 100644 --- a/tracker/gioui/theme.go +++ b/tracker/gioui/theme.go @@ -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 { diff --git a/tracker/gioui/theme.yml b/tracker/gioui/theme.yml index 03716b0..cd08210 100644 --- a/tracker/gioui/theme.yml +++ b/tracker/gioui/theme.yml @@ -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 diff --git a/tracker/int.go b/tracker/int.go index 7fada4a..a96eadd 100644 --- a/tracker/int.go +++ b/tracker/int.go @@ -34,6 +34,9 @@ type ( Octave Model DetectorWeighting Model SyntherIndex Model + SpecAnSpeed Model + SpecAnResolution Model + SpecAnChannelsInt Model ) func MakeInt(value IntValue) Int { @@ -83,6 +86,9 @@ func (m *Model) Step() Int { return MakeInt((*Step)(m)) } func (m *Model) Octave() Int { return MakeInt((*Octave)(m)) } func (m *Model) DetectorWeighting() Int { return MakeInt((*DetectorWeighting)(m)) } func (m *Model) SyntherIndex() Int { return MakeInt((*SyntherIndex)(m)) } +func (m *Model) SpecAnSpeed() Int { return MakeInt((*SpecAnSpeed)(m)) } +func (m *Model) SpecAnResolution() Int { return MakeInt((*SpecAnResolution)(m)) } +func (m *Model) SpecAnChannelsInt() Int { return MakeInt((*SpecAnChannelsInt)(m)) } // BeatsPerMinuteInt @@ -150,6 +156,32 @@ func (v *DetectorWeighting) SetValue(value int) bool { } func (v *DetectorWeighting) Range() IntRange { return IntRange{0, int(NumLoudnessTypes) - 1} } +// SpecAn stuff + +func (v *SpecAnSpeed) Value() int { return int(v.specAnSettings.Smooth) } +func (v *SpecAnSpeed) SetValue(value int) bool { + v.specAnSettings.Smooth = value + TrySend(v.broker.ToSpecAn, MsgToSpecAn{HasSettings: true, SpecSettings: v.specAnSettings}) + return true +} +func (v *SpecAnSpeed) Range() IntRange { return IntRange{SpecSpeedMin, SpecSpeedMax} } + +func (v *SpecAnResolution) Value() int { return v.specAnSettings.Resolution } +func (v *SpecAnResolution) SetValue(value int) bool { + v.specAnSettings.Resolution = value + TrySend(v.broker.ToSpecAn, MsgToSpecAn{HasSettings: true, SpecSettings: v.specAnSettings}) + return true +} +func (v *SpecAnResolution) Range() IntRange { return IntRange{SpecResolutionMin, SpecResolutionMax} } + +func (v *SpecAnChannelsInt) Value() int { return int(v.specAnSettings.ChnMode) } +func (v *SpecAnChannelsInt) SetValue(value int) bool { + v.specAnSettings.ChnMode = SpecChnMode(value) + TrySend(v.broker.ToSpecAn, MsgToSpecAn{HasSettings: true, SpecSettings: v.specAnSettings}) + return true +} +func (v *SpecAnChannelsInt) Range() IntRange { return IntRange{0, int(NumSpecChnModes) - 1} } + // InstrumentVoicesInt func (v *InstrumentVoices) Value() int { diff --git a/tracker/model.go b/tracker/model.go index 6d37ca4..b26438a 100644 --- a/tracker/model.go +++ b/tracker/model.go @@ -73,9 +73,14 @@ type ( signalAnalyzer *ScopeModel detectorResult DetectorResult + spectrum *Spectrum + weightingType WeightingType oversampling bool + specAnSettings SpecAnSettings + specAnEnabled bool + alerts []Alert dialog Dialog @@ -185,6 +190,7 @@ func (m *Model) Dialog() Dialog { return m.dialog } func (m *Model) Quitted() bool { return m.quitted } func (m *Model) DetectorResult() DetectorResult { return m.detectorResult } +func (m *Model) Spectrum() Spectrum { return *m.spectrum } // NewModelPlayer creates a new model and a player that communicates with it func NewModel(broker *Broker, synthers []sointu.Synther, midiContext MIDIContext, recoveryFilePath string) *Model { @@ -195,6 +201,7 @@ func NewModel(broker *Broker, synthers []sointu.Synther, midiContext MIDIContext m.d.Octave = 4 m.linkInstrTrack = true m.d.RecoveryFilePath = recoveryFilePath + m.spectrum = broker.GetSpectrum() m.resetSong() if recoveryFilePath != "" { if bytes2, err := os.ReadFile(m.d.RecoveryFilePath); err == nil { @@ -205,7 +212,7 @@ func NewModel(broker *Broker, synthers []sointu.Synther, midiContext MIDIContext } } TrySend(broker.ToPlayer, any(m.d.Song.Copy())) // we should be non-blocking in the constructor - m.signalAnalyzer = NewScopeModel(broker, m.d.Song.BPM) + m.signalAnalyzer = NewScopeModel(m.d.Song.BPM) m.updateDeriveData(SongChange) m.presets.load() m.updateDerivedPresetSearch() @@ -372,6 +379,7 @@ func (m *Model) ProcessMsg(msg MsgToModel) { } if msg.Reset { m.signalAnalyzer.Reset() + TrySend(m.broker.ToDetector, MsgToDetector{Reset: true}) // chain the messages: when the signal analyzer is reset, also reset the detector } switch e := msg.Data.(type) { case func(): @@ -394,6 +402,20 @@ func (m *Model) ProcessMsg(msg MsgToModel) { m.playing = e.bool case *sointu.AudioBuffer: m.signalAnalyzer.ProcessAudioBuffer(e) + // chain the messages: when we have a new audio buffer, send them to the detector and the spectrum analyzer + if m.specAnEnabled { // send buffers to spectrum analyzer only if it's enabled + clone := m.broker.GetAudioBuffer() + *clone = append(*clone, *e...) + if !TrySend(m.broker.ToSpecAn, MsgToSpecAn{Data: clone}) { + m.broker.PutAudioBuffer(clone) + } + } + if !TrySend(m.broker.ToDetector, MsgToDetector{Data: e}) { + m.broker.PutAudioBuffer(e) + } + case *Spectrum: + m.broker.PutSpectrum(m.spectrum) + m.spectrum = e } } diff --git a/tracker/scopemodel.go b/tracker/scopemodel.go index 0acbf3f..edf1c4f 100644 --- a/tracker/scopemodel.go +++ b/tracker/scopemodel.go @@ -14,8 +14,6 @@ type ( triggerChannel int lengthInBeats int bpm int - - broker *Broker } RingBuffer[T any] struct { @@ -55,9 +53,8 @@ func (r *RingBuffer[T]) WriteOnceSingle(value T) { } } -func NewScopeModel(broker *Broker, bpm int) *ScopeModel { +func NewScopeModel(bpm int) *ScopeModel { s := &ScopeModel{ - broker: broker, bpm: bpm, lengthInBeats: 4, } @@ -96,10 +93,6 @@ func (s *ScopeModel) ProcessAudioBuffer(bufPtr *sointu.AudioBuffer) { } else { s.waveForm.WriteOnce(*bufPtr) } - // chain the messages: when we have a new audio buffer, try passing it on to the detector - if !TrySend(s.broker.ToDetector, MsgToDetector{Data: bufPtr}) { - s.broker.PutAudioBuffer(bufPtr) - } } // Note: channel 1 is the first channel @@ -116,7 +109,6 @@ func (s *ScopeModel) Reset() { l := len(s.waveForm.Buffer) s.waveForm.Buffer = s.waveForm.Buffer[:0] s.waveForm.Buffer = append(s.waveForm.Buffer, make([][2]float32, l)...) - TrySend(s.broker.ToDetector, MsgToDetector{Reset: true}) // chain the messages: when the signal analyzer is reset, also reset the detector } func (s *ScopeModel) SetBpm(bpm int) { diff --git a/tracker/spectrum.go b/tracker/spectrum.go new file mode 100644 index 0000000..2adf09a --- /dev/null +++ b/tracker/spectrum.go @@ -0,0 +1,267 @@ +package tracker + +import ( + "math" + "math/cmplx" + + "github.com/viterin/vek/vek32" + "github.com/vsariola/sointu" +) + +type ( + SpecAnalyzer struct { + settings SpecAnSettings + broker *Broker + chunker chunker + temp specTemp + } + + SpecAnSettings struct { + ChnMode SpecChnMode + Smooth int + Resolution int + } + + SpecChnMode int + Spectrum [2][]float32 + + specTemp struct { + power [2][]float32 + window []float32 // window weighting function + normFactor float32 // normalization factor, to account for the windowing + bitPerm []int // bit-reversal permutation table + tmpC []complex128 // temporary buffer for FFT + tmp1, tmp2 []float32 // temporary buffers for processing + } + + BiquadCoeffs struct { + b0, b1, b2 float32 + a0, a1, a2 float32 + } + + SpecAnEnabled Model +) + +const ( + SpecResolutionMin = -3 + SpecResolutionMax = 3 +) + +const ( + SpecSpeedMin = -3 + SpecSpeedMax = 3 +) + +const ( + SpecChnModeSum SpecChnMode = iota // calculate a single combined spectrum for both channels + SpecChnModeSeparate // calculate separate spectrums for left and right channels + NumSpecChnModes +) + +func (m *Model) SpecAnEnabled() Bool { return MakeEnabledBool((*simpleBool)(&m.specAnEnabled)) } + +func NewSpecAnalyzer(broker *Broker) *SpecAnalyzer { + ret := &SpecAnalyzer{broker: broker} + ret.init(SpecAnSettings{}) + return ret +} + +func (m *Model) BiquadCoeffs() (coeffs BiquadCoeffs, ok bool) { + i := m.d.InstrIndex + u := m.d.UnitIndex + if i < 0 || i >= len(m.d.Song.Patch) || u < 0 || u >= len(m.d.Song.Patch[i].Units) { + return BiquadCoeffs{}, false + } + switch m.d.Song.Patch[i].Units[u].Type { + case "filter": + p := m.d.Song.Patch[i].Units[u].Parameters + f := float32(p["frequency"]) / 128 + f *= f + r := float32(p["resonance"]) / 128 + // The equations for the filter are: + // s1[n+1] = s1[n] + f*s2[n] + // h = u - s1[n+1] - r*s2[n] + // s2[n+1] = s2[n] + f*h = s2[n] + f*(u-s1[n]-f*s2[n]-r*s2[n]) = - f*s1[n]+(1-f*r-f*f)*s2[n] + f*u + // y_low[n] = s1[n+1], y_band[n] = s2[n+1], y_high[n] = -s1[n+1]-r*s2[n]+u + // This gives state space representation + // s(n+1) = A*s(n)+B*u, where A = [1 f;-f 1-f*r-f*f] and B = [0;f] + // y(n) = C*s(n)+D*u, where + // C_low = [z 0], C_band = [0 z], C_high = [-z -r], D_high = [1] (note we use those z:s in C to account for those 1 sample time shifts) + // The transfer function is then H(z) = C*(zI-A)^-1*B + D + // z*I-A = [z-1 -f; f z+f*r+f*f-1] + // Calculate (zI-A)^-1*B: + // (z*I-A)^-1*B = 1/det * [z+f*r+f*f-1 f; -f z-1] * [0;f] = 1/det * f * [f; z-1], where + // det = (z+f*r+f*f-1)*(z-1)+f^2 = z*z+z*f*r+z*f*f-z-z-f*r-f*f+1+f^2 = z*z + (r*f+f*f-2)*z + 1-f*r = a0*z^2 + a1*z + a2 + // Low: [z 0]*f*[f;z-1] / det = f*f*z / det = b1 * z / det + // Band: [0 z]*f*[f;z-1] / det = (f*z^2-f*z) / det = (b0*z^2 + b1*z) / det + // High: [-z -r]*f*[f;z-1] / det + 1 = ((-f*f-r*f)*z+r*f)/det + 1 = ((-f*f-r*f)*z+r*f+det)/det = (z^2-2*z+1)/det = (b0*z^2 + b1*z + b2)/det + // Negative versions have only b coefficients negated + var a0 float32 = 1 + var a1 float32 = r*f + f*f - 2 + var a2 float32 = 1 - f*r + var b0, b1, b2 float32 + b1 += f * f * float32(p["lowpass"]) + b0 += f * float32(p["bandpass"]) + b1 -= f * float32(p["bandpass"]) + b0 += float32(p["highpass"]) + b1 += -2 * float32(p["highpass"]) + b2 += float32(p["highpass"]) + return BiquadCoeffs{a0: a0, a1: a1, a2: a2, b0: b0, b1: b1, b2: b2}, true + case "belleq": + f := float32(m.d.Song.Patch[i].Units[u].Parameters["frequency"]) / 128 + band := float32(m.d.Song.Patch[i].Units[u].Parameters["bandwidth"]) / 128 + gain := float32(m.d.Song.Patch[i].Units[u].Parameters["gain"]) / 128 + omega0 := 2 * f * f + alpha := float32(math.Sin(float64(omega0))) * 2 * band + A := float32(math.Pow(2, float64(gain-.5)*6.643856189774724)) + u, v := alpha*A, alpha/A + return BiquadCoeffs{ + b0: 1 + u, + b1: -2 * float32(math.Cos(float64(omega0))), + b2: 1 - u, + a0: 1 + v, + a1: -2 * float32(math.Cos(float64(omega0))), + a2: 1 - v, + }, true + default: + return BiquadCoeffs{}, false + } +} + +func (c *BiquadCoeffs) Gain(omega float32) float32 { + e := cmplx.Rect(1, -float64(omega)) + return float32(cmplx.Abs((complex(float64(c.b0), 0) + complex(float64(c.b1), 0)*e + complex(float64(c.b2), 0)*(e*e)) / + (complex(float64(c.a0), 0) + complex(float64(c.a1), 0)*e + complex(float64(c.a2), 0)*e*e))) +} + +func (s *SpecAnalyzer) Run() { + for { + select { + case <-s.broker.CloseSpecAn: + close(s.broker.FinishedSpecAn) + return + case msg := <-s.broker.ToSpecAn: + s.handleMsg(msg) + } + } +} + +func (s *SpecAnalyzer) handleMsg(msg MsgToSpecAn) { + if msg.HasSettings { + s.init(msg.SpecSettings) + } + switch m := msg.Data.(type) { + case *sointu.AudioBuffer: + buf := *m + l := len(s.temp.window) + // 50% overlap with the windows + s.chunker.Process(buf, l, l>>1, func(chunk sointu.AudioBuffer) { + TrySend(s.broker.ToModel, MsgToModel{Data: s.update(chunk)}) + }) + s.broker.PutAudioBuffer(m) + default: + // unknown message type; ignore + } +} + +func (a *SpecAnalyzer) init(s SpecAnSettings) { + s.Resolution = min(max(s.Resolution, SpecResolutionMin), SpecResolutionMax) + 10 + a.settings = s + n := 1 << s.Resolution + a.temp = specTemp{ + power: [2][]float32{make([]float32, n/2), make([]float32, n/2)}, + window: make([]float32, n), + bitPerm: make([]int, n), + tmpC: make([]complex128, n), + tmp1: make([]float32, n), + tmp2: make([]float32, n), + } + for i := range n { + // Hanning window + w := float32(0.5 * (1 - math.Cos(2*math.Pi*float64(i)/float64(n-1)))) + a.temp.window[i] = w + a.temp.normFactor += w + // initialize the bit-reversal permutation table + a.temp.bitPerm[i] = i + } + // compute the bit-reversal permutation + for i, j := 1, 0; i < n; i++ { + bit := n >> 1 + for ; j&bit != 0; bit >>= 1 { + j ^= bit + } + j ^= bit + + if i < j { + a.temp.bitPerm[i], a.temp.bitPerm[j] = a.temp.bitPerm[j], a.temp.bitPerm[i] + } + } +} + +func (s *SpecAnalyzer) update(buf sointu.AudioBuffer) *Spectrum { + ret := s.broker.GetSpectrum() + switch s.settings.ChnMode { + case SpecChnModeSeparate: + s.process(buf, 0) + s.process(buf, 1) + ret[0] = append(ret[0], s.temp.power[0]...) + ret[1] = append(ret[1], s.temp.power[1]...) + case SpecChnModeSum: + s.process(buf, 0) + s.process(buf, 1) + ret[0] = append(ret[0], s.temp.power[0]...) + vek32.Add_Inplace(ret[0], s.temp.power[1]) + } + // convert to decibels + for c := range 2 { + vek32.Log10_Inplace(ret[c]) + vek32.MulNumber_Inplace(ret[c], 10) + } + return ret +} + +func (sd *SpecAnalyzer) process(buf sointu.AudioBuffer, channel int) { + for i := range buf { // de-interleave + sd.temp.tmp1[i] = removeNaNsAndClamp(buf[i][channel]) + } + vek32.Mul_Inplace(sd.temp.tmp1, sd.temp.window) // apply windowing + vek32.Gather_Into(sd.temp.tmp2, sd.temp.tmp1, sd.temp.bitPerm) // bit-reversal permutation + // convert into complex numbers + c := sd.temp.tmpC + for i := range c { + c[i] = complex(float64(sd.temp.tmp2[i]), 0) + } + // FFT + n := len(c) + for len := 2; len <= n; len <<= 1 { + ang := 2 * math.Pi / float64(len) + wlen := complex(math.Cos(ang), math.Sin(ang)) + for i := 0; i < n; i += len { + w := complex(1, 0) + for j := 0; j < len/2; j++ { + u := c[i+j] + v := c[i+j+len/2] * w + c[i+j] = u + v + c[i+j+len/2] = u - v + w *= wlen + } + } + } + // take absolute values of the first half, including nyquist frequency but excluding DC + m := n / 2 + t1 := sd.temp.tmp1[:m] + t2 := sd.temp.tmp2[:m] + for i := 0; i < m; i++ { + t1[i] = float32(cmplx.Abs(c[1+i])) // do not include DC + } + // square the amplitudes to get power + vek32.Mul_Into(t2, t1, t1) + vek32.DivNumber_Inplace(t2, sd.temp.normFactor*sd.temp.normFactor) // normalize for windowing + // Since we are using a real-valued FFT, we need to double the values except for Nyquist (and DC, but we don't have that here) + vek32.MulNumber_Inplace(t2[:m-1], 2) + // calculate difference to current spectrum and add back, multiplied by smoothing factor + vek32.Sub_Inplace(t2, sd.temp.power[channel]) + alpha := float32(math.Pow(2, float64(sd.settings.Smooth-SpecSpeedMax))) + vek32.MulNumber_Inplace(t2, alpha) + vek32.Add_Inplace(sd.temp.power[channel], t2) +}