From 2303e89bbdab31d01bb3fc8a5c82aba0db35e3d1 Mon Sep 17 00:00:00 2001 From: "5684185+vsariola@users.noreply.github.com" <5684185+vsariola@users.noreply.github.com> Date: Tue, 30 Dec 2025 22:43:35 +0200 Subject: [PATCH] drafting --- cmd/sointu-track/main.go | 4 + cmd/sointu-vsti/main.go | 4 + tracker/broker.go | 29 ++++-- tracker/detector.go | 42 ++------ tracker/gioui/song_panel.go | 9 ++ tracker/gioui/specanalyzer.go | 106 ++++++++++++++++++++ tracker/int.go | 32 ++++++ tracker/model.go | 21 +++- tracker/scopemodel.go | 10 +- tracker/spectrum.go | 183 +++++++++++++++++++++++----------- 10 files changed, 330 insertions(+), 110 deletions(-) create mode 100644 tracker/gioui/specanalyzer.go 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/tracker/broker.go b/tracker/broker.go index 8723723..e764109 100644 --- a/tracker/broker.go +++ b/tracker/broker.go @@ -37,7 +37,7 @@ 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 any + ToSpecAn chan MsgToSpecAn CloseDetector chan struct{} CloseGUI chan struct{} @@ -53,7 +53,7 @@ type ( mIDIEventsToGUI atomic.Bool bufferPool sync.Pool - f32slicePool sync.Pool + spectrumPool sync.Pool } // MsgToModel is a message sent to the model. The most often sent data @@ -97,6 +97,12 @@ type ( Param int } + MsgToSpecAn struct { + SpecSettings SpecAnSettings + HasSettings bool + Data any + } + GUIMessageKind int ) @@ -112,7 +118,7 @@ func NewBroker() *Broker { ToModel: make(chan MsgToModel, 1024), ToDetector: make(chan MsgToDetector, 1024), ToGUI: make(chan any, 1024), - ToSpecAn: make(chan any, 1024), + ToSpecAn: make(chan MsgToSpecAn, 1024), CloseDetector: make(chan struct{}, 1), CloseGUI: make(chan struct{}, 1), CloseSpecAn: make(chan struct{}, 1), @@ -120,7 +126,7 @@ func NewBroker() *Broker { FinishedDetector: make(chan struct{}), FinishedSpecAn: make(chan struct{}), bufferPool: sync.Pool{New: func() any { return &sointu.AudioBuffer{} }}, - f32slicePool: sync.Pool{New: func() any { return &[]float32{} }}, + spectrumPool: sync.Pool{New: func() any { return &Spectrum{} }}, } } @@ -148,15 +154,18 @@ func (b *Broker) PutAudioBuffer(buf *sointu.AudioBuffer) { b.bufferPool.Put(buf) } -func (b *Broker) GetF32Slice(size int) *[]float32 { - return b.f32slicePool.Get().(*[]float32) +func (b *Broker) GetSpectrum() *Spectrum { + return b.spectrumPool.Get().(*Spectrum) } -func (b *Broker) PutF32Slice(s *[]float32) { - if len(*s) > 0 { - *s = (*s)[:0] +func (b *Broker) PutSpectrum(s *Spectrum) { + if len((*s)[0]) > 0 { + (*s)[0] = (*s)[0][:0] } - b.f32slicePool.Put(s) + 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. diff --git a/tracker/detector.go b/tracker/detector.go index 51ae97b..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 @@ -136,26 +136,7 @@ func (s *Detector) handleMsg(msg MsgToDetector) { switch data := msg.Data.(type) { case *sointu.AudioBuffer: buf := *data - for len(buf) > 0 { - 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{ @@ -163,7 +144,7 @@ func (s *Detector) handleMsg(msg MsgToDetector) { Peaks: s.peakDetector.update(chunk), }, }) - } + }) s.broker.PutAudioBuffer(data) } } @@ -438,20 +419,13 @@ func (d *peakDetector) reset() { } } -func (c *chunker) Process(input sointu.AudioBuffer, windowLength, overlap int, cb func(sointu.AudioBuffer)) sointu.AudioBuffer { +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) >= windowLength { - cb(b[:windowLength]) - b = b[windowLength-overlap:] + for len(b) >= windowLen { + cb(b[:windowLen]) + b = b[windowLen-overlap:] } copy(c.buffer, b) c.buffer = c.buffer[:len(b)] - for { - if len(c.buffer) > 0 { - l := min(len(input), windowLength-len(c.buffer)) - c.buffer = append(c.buffer, input[:l]...) - input = input[l:] - } - - } } diff --git a/tracker/gioui/song_panel.go b/tracker/gioui/song_panel.go index 04a1383..ecf0b76 100644 --- a/tracker/gioui/song_panel.go +++ b/tracker/gioui/song_panel.go @@ -26,6 +26,7 @@ type SongPanel struct { LoudnessExpander *Expander PeakExpander *Expander CPUExpander *Expander + SpectrumExpander *Expander WeightingTypeBtn *Clickable OversamplingBtn *Clickable @@ -39,6 +40,8 @@ type SongPanel struct { Scope *OscilloscopeState + SpectrumState *SpectrumState + MenuBar *MenuBar PlayBar *PlayBar } @@ -63,6 +66,9 @@ func NewSongPanel(tr *Tracker) *SongPanel { LoudnessExpander: &Expander{}, PeakExpander: &Expander{}, CPUExpander: &Expander{}, + SpectrumExpander: &Expander{}, + + SpectrumState: NewSpectrumState(), } return ret } @@ -257,6 +263,9 @@ func (t *SongPanel) layoutSongOptions(gtx C) D { 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.Flexed(1, func(gtx C) D { + return t.SpectrumExpander.Layout(gtx, tr.Theme, "Spectrum", func(gtx C) D { return D{} }, t.SpectrumState.Layout) + }), layout.Rigid(Label(tr.Theme, &tr.Theme.SongPanel.Version, version.VersionOrHash).Layout), ) } diff --git a/tracker/gioui/specanalyzer.go b/tracker/gioui/specanalyzer.go new file mode 100644 index 0000000..966925e --- /dev/null +++ b/tracker/gioui/specanalyzer.go @@ -0,0 +1,106 @@ +package gioui + +import ( + "image" + + "gioui.org/layout" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/unit" + "github.com/vsariola/sointu/tracker" +) + +type ( + SpectrumState struct { + resolutionNumber *NumericUpDownState + smoothingBtn *Clickable + chnModeBtn *Clickable + } +) + +func NewSpectrumState() *SpectrumState { + return &SpectrumState{ + resolutionNumber: NewNumericUpDownState(), + smoothingBtn: new(Clickable), + 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(24)}.Layout + rightSpacer := layout.Spacer{Width: unit.Dp(6)}.Layout + + var chnModeTxt string = "???" + switch tracker.SpecChnMode(t.Model.SpecAnChannelsInt().Value()) { + case tracker.SpecChnModeCombine: + chnModeTxt = "Combine" + case tracker.SpecChnModeSeparate: + chnModeTxt = "Separate" + case tracker.SpecChnModeLeft: + chnModeTxt = "Left" + case tracker.SpecChnModeRight: + chnModeTxt = "Right" + case tracker.SpecChnModeOff: + chnModeTxt = "Off" + } + + var smoothTxt string = "???" + switch tracker.SpecSmoothing(t.Model.SpecAnSmoothing().Value()) { + case tracker.SpecSmoothingSlow: + smoothTxt = "Slow" + case tracker.SpecSmoothingMedium: + smoothTxt = "Medium" + case tracker.SpecSmoothingFast: + smoothTxt = "Fast" + } + + resolution := NumUpDown(t.Model.SpecAnResolution(), t.Theme, s.resolutionNumber, "Resolution") + chnModeBtn := Btn(t.Theme, &t.Theme.Button.Filled, s.chnModeBtn, chnModeTxt, "Channel mode") + smoothBtn := Btn(t.Theme, &t.Theme.Button.Filled, s.smoothingBtn, smoothTxt, "Smoothing") + + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Flexed(1, s.drawSpectrum), + layout.Rigid(func(gtx C) D { + return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(leftSpacer), + layout.Flexed(1, func(gtx C) D { return D{Size: gtx.Constraints.Min} }), + layout.Rigid(chnModeBtn.Layout), + layout.Rigid(smoothBtn.Layout), + layout.Rigid(resolution.Layout), + layout.Rigid(rightSpacer), + ) + }), + ) +} + +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)) + } + for s.smoothingBtn.Clicked(gtx) { + r := t.Model.SpecAnSmoothing().Range() + t.Model.SpecAnSmoothing().SetValue((t.SpecAnSmoothing().Value()+1)%(r.Max-r.Min+1) + r.Min) + } +} + +func (s *SpectrumState) drawSpectrum(gtx C) D { + t := TrackerFromContext(gtx) + for chn := range 2 { + paint.ColorOp{Color: t.Theme.Oscilloscope.CurveColors[chn]}.Add(gtx.Ops) + p := t.Spectrum()[chn] + if len(p) <= 0 { + continue + } + fillRect(gtx, clip.Rect{Min: image.Pt(0, 0), Max: image.Pt(gtx.Constraints.Max.X, 1)}) + fillRect(gtx, clip.Rect{Min: image.Pt(0, gtx.Constraints.Min.Y-1), Max: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y)}) + for px := range gtx.Constraints.Max.X { + y2 := gtx.Constraints.Max.Y - 1 + y1 := int(-p[px*len(p)/gtx.Constraints.Max.X] / 80 * float32(y2)) + 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)} +} diff --git a/tracker/int.go b/tracker/int.go index 7fada4a..03bd109 100644 --- a/tracker/int.go +++ b/tracker/int.go @@ -34,6 +34,9 @@ type ( Octave Model DetectorWeighting Model SyntherIndex Model + SpecAnSmoothing 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) SpecAnSmoothing() Int { return MakeInt((*SpecAnSmoothing)(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 *SpecAnSmoothing) Value() int { return int(v.specAnSettings.Smooth) } +func (v *SpecAnSmoothing) SetValue(value int) bool { + v.specAnSettings.Smooth = SpecSmoothing(value) + TrySend(v.broker.ToSpecAn, MsgToSpecAn{HasSettings: true, SpecSettings: v.specAnSettings}) + return true +} +func (v *SpecAnSmoothing) Range() IntRange { return IntRange{0, int(NumSpecSmoothing) - 1} } + +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..4398cf8 100644 --- a/tracker/model.go +++ b/tracker/model.go @@ -73,9 +73,13 @@ type ( signalAnalyzer *ScopeModel detectorResult DetectorResult + spectrum *Spectrum + weightingType WeightingType oversampling bool + specAnSettings SpecAnSettings + alerts []Alert dialog Dialog @@ -185,6 +189,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 +200,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 +211,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 +378,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 +401,18 @@ 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 + clone := m.broker.GetAudioBuffer() + *clone = append(*clone, *e...) + if !TrySend(m.broker.ToDetector, MsgToDetector{Data: e}) { + m.broker.PutAudioBuffer(e) + } + if !TrySend(m.broker.ToSpecAn, MsgToSpecAn{Data: clone}) { + m.broker.PutAudioBuffer(clone) + } + 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 index 6249a43..266bc07 100644 --- a/tracker/spectrum.go +++ b/tracker/spectrum.go @@ -4,34 +4,35 @@ import ( "math" "math/cmplx" + "github.com/viterin/vek/vek32" "github.com/vsariola/sointu" ) type ( SpecAnalyzer struct { - settings SpecSettings + settings SpecAnSettings broker *Broker + chunker chunker temp specTemp } - SpecSettings struct { - Channels SpecChannels - Smooth SpecSmooth + SpecAnSettings struct { + ChnMode SpecChnMode + Smooth SpecSmoothing Resolution int } - SpecChannels int - SpecSmooth int - Spectrum []Decibel - SpecResult []Spectrum + SpecChnMode int + SpecSmoothing int + Spectrum [2][]float32 specTemp struct { - spectra []Spectrum - chunk sointu.AudioBuffer - weight []float32 // window weighting function + power [2][]float32 + window []float32 // window weighting function normFactor float32 // normalization factor, to account for the windowing - perm []int // bit-reversal permutation table - tmp []complex128 // temporary buffer for FFT + bitPerm []int // bit-reversal permutation table + tmpC []complex128 // temporary buffer for FFT + tmp1, tmp2 []float32 // temporary buffers for processing } ) @@ -41,23 +42,35 @@ const ( ) const ( - SpecChannelsOff SpecChannels = iota // no spectrum analysis is done to save CPU resources - SpecChannelsCombined // calculate a single combined spectrum for both channels - SpecChannelsSeparated // calculate separate spectrums for left and right channels - SpecChannelsLeft // calculate spectrum only for the left channel - SpecChannelsRight // calculate spectrum only for the right channel + SpecChnModeOff SpecChnMode = iota // no spectrum analysis is done to save CPU resources + SpecChnModeCombine // calculate a single combined spectrum for both channels + SpecChnModeSeparate // calculate separate spectrums for left and right channels + SpecChnModeLeft // calculate spectrum only for the left channel + SpecChnModeRight // calculate spectrum only for the right channel + NumSpecChnModes ) const ( - SpecSmoothSlow SpecSmooth = iota - SpecSmoothMedium - SpecSmoothFast + SpecSmoothingSlow SpecSmoothing = iota + SpecSmoothingMedium + SpecSmoothingFast + NumSpecSmoothing ) -var spectrumSmoothingMap map[SpecSmooth]float32 = map[SpecSmooth]float32{ - SpecSmoothSlow: 0.05, - SpecSmoothMedium: 0.2, - SpecSmoothFast: 1.0, +var spectrumSmoothingMap map[SpecSmoothing]float32 = map[SpecSmoothing]float32{ + SpecSmoothingSlow: 0.05, + SpecSmoothingMedium: 0.2, + SpecSmoothingFast: 1.0, +} + +func NewSpecAnalyzer(broker *Broker) *SpecAnalyzer { + ret := &SpecAnalyzer{broker: broker} + ret.init(SpecAnSettings{ + ChnMode: SpecChnModeCombine, + Smooth: SpecSmoothingMedium, + Resolution: 10, + }) + return ret } func (s *SpecAnalyzer) Run() { @@ -72,37 +85,45 @@ func (s *SpecAnalyzer) Run() { } } -func (s *SpecAnalyzer) handleMsg(msg any) { - switch m := msg.(type) { - case SpecSettings: - if s.settings != m { - s.init(m) +func (s *SpecAnalyzer) handleMsg(msg MsgToSpecAn) { + if msg.HasSettings { + s.init(msg.SpecSettings) + } + switch m := msg.Data.(type) { + case *sointu.AudioBuffer: + if s.settings.ChnMode != SpecChnModeOff { + 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)}) + }) } - case sointu.AudioBuffer: - s.update(m) + s.broker.PutAudioBuffer(m) default: // unknown message type; ignore } } -func (a *SpecAnalyzer) init(s SpecSettings) { +func (a *SpecAnalyzer) init(s SpecAnSettings) { s.Resolution = min(max(s.Resolution, SpecResolutionMin), SpecResolutionMax) a.settings = s n := 1 << s.Resolution a.temp = specTemp{ - spectra: make([]Spectrum, 0), - chunk: make(sointu.AudioBuffer, n), - weight: make([]float32, n), - perm: make([]int, n), - tmp: make([]complex128, n), + 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.weight[i] = w + a.temp.window[i] = w a.temp.normFactor += w // initialize the bit-reversal permutation table - a.temp.perm[i] = i + a.temp.bitPerm[i] = i } // compute the bit-reversal permutation for i, j := 1, 0; i < n; i++ { @@ -111,36 +132,86 @@ func (a *SpecAnalyzer) init(s SpecSettings) { j ^= bit } j ^= bit + if i < j { - a.temp.perm[i], a.temp.perm[j] = a.temp.perm[j], a.temp.perm[i] + a.temp.bitPerm[i], a.temp.bitPerm[j] = a.temp.bitPerm[j], a.temp.bitPerm[i] } } } -func (sd *spectrumDetector) update(input []float32) { - c := sd.tmp - for i := range sd.tmp { - p := sd.perm[i] - c[i] = complex(float64(input[p]*sd.window[p]), 0) +func (s *SpecAnalyzer) update(buf sointu.AudioBuffer) *Spectrum { + ret := s.broker.GetSpectrum() + switch s.settings.ChnMode { + case SpecChnModeLeft: + s.process(buf, 0) + ret[0] = append(ret[0], s.temp.power[0]...) + case SpecChnModeRight: + s.process(buf, 1) + ret[1] = append(ret[1], s.temp.power[1]...) + 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 SpecChnModeCombine: + 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]) + vek32.MulNumber_Inplace(ret[0], 0.5) } + // convert to decibels + for c := range 2 { + vek32.MaximumNumber_Inplace(ret[c], 1e-8) + vek32.MinimumNumber_Inplace(ret[c], 1e8) + 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] = 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 l := 2; l <= n; l <<= 1 { - ang := 2 * math.Pi / float64(l) + 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 += l { + for i := 0; i < n; i += len { w := complex(1, 0) - for j := 0; j < l/2; j++ { + for j := 0; j < len/2; j++ { u := c[i+j] - v := c[i+j+l/2] * w + v := c[i+j+len/2] * w c[i+j] = u + v - c[i+j+l/2] = u - v + c[i+j+len/2] = u - v w *= wlen } } } - for i := range input { - a := cmplx.Abs(c[i]) - power := float32(a*a) / sd.normFactor - sd.spectrum[i] += sd.alpha * (power - sd.spectrum[i]) + // 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 := spectrumSmoothingMap[sd.settings.Smooth] + vek32.MulNumber_Inplace(t2, alpha) + vek32.Add_Inplace(sd.temp.power[channel], t2) }