From f765d75fde32f24cdc57a812cb641124e3ee93e7 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] drafting spectrum analyzer --- tracker/broker.go | 21 ++++++- tracker/detector.go | 25 +++++++- tracker/spectrum.go | 146 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 tracker/spectrum.go diff --git a/tracker/broker.go b/tracker/broker.go index c6d8f98..8723723 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 any 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 + f32slicePool sync.Pool } // MsgToModel is a message sent to the model. The most often sent data @@ -108,11 +112,15 @@ func NewBroker() *Broker { ToModel: make(chan MsgToModel, 1024), ToDetector: make(chan MsgToDetector, 1024), ToGUI: make(chan any, 1024), + ToSpecAn: make(chan any, 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{} }}, + f32slicePool: sync.Pool{New: func() any { return &[]float32{} }}, } } @@ -140,6 +148,17 @@ 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) PutF32Slice(s *[]float32) { + if len(*s) > 0 { + *s = (*s)[:0] + } + b.f32slicePool.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..51ae97b 100644 --- a/tracker/detector.go +++ b/tracker/detector.go @@ -62,6 +62,10 @@ type ( history [11]float32 tmp, tmp2 []float32 } + + chunker struct { + buffer sointu.AudioBuffer + } ) const ( @@ -132,7 +136,7 @@ func (s *Detector) handleMsg(msg MsgToDetector) { switch data := msg.Data.(type) { case *sointu.AudioBuffer: buf := *data - for { + 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)) @@ -160,6 +164,7 @@ func (s *Detector) handleMsg(msg MsgToDetector) { }, }) } + s.broker.PutAudioBuffer(data) } } @@ -432,3 +437,21 @@ func (d *peakDetector) reset() { d.maxPower[chn] = 0 } } + +func (c *chunker) Process(input sointu.AudioBuffer, windowLength, overlap int, cb func(sointu.AudioBuffer)) sointu.AudioBuffer { + b := c.buffer + for len(b) >= windowLength { + cb(b[:windowLength]) + b = b[windowLength-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/spectrum.go b/tracker/spectrum.go new file mode 100644 index 0000000..6249a43 --- /dev/null +++ b/tracker/spectrum.go @@ -0,0 +1,146 @@ +package tracker + +import ( + "math" + "math/cmplx" + + "github.com/vsariola/sointu" +) + +type ( + SpecAnalyzer struct { + settings SpecSettings + broker *Broker + temp specTemp + } + + SpecSettings struct { + Channels SpecChannels + Smooth SpecSmooth + Resolution int + } + + SpecChannels int + SpecSmooth int + Spectrum []Decibel + SpecResult []Spectrum + + specTemp struct { + spectra []Spectrum + chunk sointu.AudioBuffer + weight []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 + } +) + +const ( + SpecResolutionMin = 7 + SpecResolutionMax = 16 +) + +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 +) + +const ( + SpecSmoothSlow SpecSmooth = iota + SpecSmoothMedium + SpecSmoothFast +) + +var spectrumSmoothingMap map[SpecSmooth]float32 = map[SpecSmooth]float32{ + SpecSmoothSlow: 0.05, + SpecSmoothMedium: 0.2, + SpecSmoothFast: 1.0, +} + +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 any) { + switch m := msg.(type) { + case SpecSettings: + if s.settings != m { + s.init(m) + } + case sointu.AudioBuffer: + s.update(m) + default: + // unknown message type; ignore + } +} + +func (a *SpecAnalyzer) init(s SpecSettings) { + 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), + } + 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.normFactor += w + // initialize the bit-reversal permutation table + a.temp.perm[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.perm[i], a.temp.perm[j] = a.temp.perm[j], a.temp.perm[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) + } + n := len(c) + for l := 2; l <= n; l <<= 1 { + ang := 2 * math.Pi / float64(l) + wlen := complex(math.Cos(ang), math.Sin(ang)) + for i := 0; i < n; i += l { + w := complex(1, 0) + for j := 0; j < l/2; j++ { + u := c[i+j] + v := c[i+j+l/2] * w + c[i+j] = u + v + c[i+j+l/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]) + } +}