mirror of
https://github.com/vsariola/sointu.git
synced 2026-03-19 13:20:37 -04:00
parent
4d09e04a49
commit
3a7010f897
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
19
patch.go
19
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"
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)]
|
||||
}
|
||||
|
||||
@ -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
186
tracker/gioui/plot.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
217
tracker/gioui/specanalyzer.go
Normal file
217
tracker/gioui/specanalyzer.go
Normal 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())
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
267
tracker/spectrum.go
Normal file
267
tracker/spectrum.go
Normal file
@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user