mirror of
https://github.com/vsariola/sointu.git
synced 2025-07-18 21:14:31 -04:00
feat(tracker): oscilloscope and LUFS / true peak detection
In addition to the oscilloscope and loudness/peak detections, this commit refactors all the channels between components (i.e. ModelMessages and PlayerMessages) etc. into a new class Broker. This was done because now we have one more goroutine running: a Detector, where the loudness / true peak detection is done in another thread. The different threads/components are only aware of the Broker and communicate through it. Currently, it's just a collection of channels, so it's many-to-one communication, but in the future, we could change Broker to have many-to-one-to-many communication. Related to #61
This commit is contained in:
parent
86c65939bb
commit
ec222bd67d
183
tracker/gioui/oscilloscope.go
Normal file
183
tracker/gioui/oscilloscope.go
Normal file
@ -0,0 +1,183 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"math"
|
||||
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/pointer"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/widget/material"
|
||||
"github.com/vsariola/sointu/tracker"
|
||||
)
|
||||
|
||||
type (
|
||||
Oscilloscope struct {
|
||||
onceBtn *BoolClickable
|
||||
wrapBtn *BoolClickable
|
||||
lengthInRowsNumber *NumberInput
|
||||
triggerChannelNumber *NumberInput
|
||||
xScale int
|
||||
xOffset float32
|
||||
dragging bool
|
||||
dragId pointer.ID
|
||||
dragStartPx float32
|
||||
}
|
||||
|
||||
OscilloscopeStyle struct {
|
||||
Oscilloscope *Oscilloscope
|
||||
Wave tracker.RingBuffer[[2]float32]
|
||||
Colors [2]color.NRGBA
|
||||
ClippedColor color.NRGBA
|
||||
Theme *material.Theme
|
||||
}
|
||||
)
|
||||
|
||||
func NewOscilloscope(model *tracker.Model) *Oscilloscope {
|
||||
return &Oscilloscope{
|
||||
onceBtn: NewBoolClickable(model.SignalAnalyzer().Once().Bool()),
|
||||
wrapBtn: NewBoolClickable(model.SignalAnalyzer().Wrap().Bool()),
|
||||
lengthInRowsNumber: NewNumberInput(model.SignalAnalyzer().LengthInRows().Int()),
|
||||
triggerChannelNumber: NewNumberInput(model.SignalAnalyzer().TriggerChannel().Int()),
|
||||
}
|
||||
}
|
||||
|
||||
func LineOscilloscope(s *Oscilloscope, wave tracker.RingBuffer[[2]float32], th *material.Theme) *OscilloscopeStyle {
|
||||
return &OscilloscopeStyle{Oscilloscope: s, Wave: wave, Colors: [2]color.NRGBA{primaryColor, secondaryColor}, Theme: th, ClippedColor: errorColor}
|
||||
}
|
||||
|
||||
func (s *OscilloscopeStyle) Layout(gtx C) D {
|
||||
wrapBtnStyle := ToggleButton(gtx, s.Theme, s.Oscilloscope.wrapBtn, "Wrap")
|
||||
onceBtnStyle := ToggleButton(gtx, s.Theme, s.Oscilloscope.onceBtn, "Once")
|
||||
triggerChannelStyle := NumericUpDown(s.Theme, s.Oscilloscope.triggerChannelNumber, "Trigger channel")
|
||||
lengthNumberStyle := NumericUpDown(s.Theme, s.Oscilloscope.lengthInRowsNumber, "Buffer length in rows")
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Flexed(1, func(gtx C) D { return s.layoutWave(gtx) }),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(Label("TRG:", white, s.Theme.Shaper)),
|
||||
layout.Rigid(triggerChannelStyle.Layout),
|
||||
layout.Rigid(onceBtnStyle.Layout),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(Label("BUF:", white, s.Theme.Shaper)),
|
||||
layout.Rigid(lengthNumberStyle.Layout),
|
||||
layout.Rigid(wrapBtnStyle.Layout),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *OscilloscopeStyle) layoutWave(gtx C) D {
|
||||
s.update(gtx)
|
||||
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()
|
||||
event.Op(gtx.Ops, s.Oscilloscope)
|
||||
paint.ColorOp{Color: disabledTextColor}.Add(gtx.Ops)
|
||||
cursorX := int(s.sampleToPx(gtx, float32(s.Wave.Cursor)))
|
||||
stack := clip.Rect{Min: image.Pt(cursorX, 0), Max: image.Pt(cursorX+1, gtx.Constraints.Max.Y)}.Push(gtx.Ops)
|
||||
paint.PaintOp{}.Add(gtx.Ops)
|
||||
stack.Pop()
|
||||
for chn := 0; chn < 2; chn++ {
|
||||
paint.ColorOp{Color: s.Colors[chn]}.Add(gtx.Ops)
|
||||
clippedColorSet := false
|
||||
yprev := int((s.Wave.Buffer[0][chn] + 1) / 2 * float32(gtx.Constraints.Max.Y))
|
||||
for px := 0; px < gtx.Constraints.Max.X; px++ {
|
||||
x := int(s.pxToSample(gtx, float32(px)))
|
||||
if x < 0 || x >= len(s.Wave.Buffer) {
|
||||
continue
|
||||
}
|
||||
y := int((s.Wave.Buffer[x][chn] + 1) / 2 * float32(gtx.Constraints.Max.Y))
|
||||
if y < 0 {
|
||||
y = 0
|
||||
} else if y >= gtx.Constraints.Max.Y {
|
||||
y = gtx.Constraints.Max.Y - 1
|
||||
}
|
||||
y1, y2 := yprev, y
|
||||
if y < yprev {
|
||||
y1, y2 = y, yprev-1
|
||||
} else if y > yprev {
|
||||
y1++
|
||||
}
|
||||
clipped := false
|
||||
if y1 == y2 && y1 == 0 {
|
||||
clipped = true
|
||||
}
|
||||
if y1 == y2 && y1 == gtx.Constraints.Max.Y-1 {
|
||||
clipped = true
|
||||
}
|
||||
if clippedColorSet != clipped {
|
||||
if clipped {
|
||||
paint.ColorOp{Color: s.ClippedColor}.Add(gtx.Ops)
|
||||
} else {
|
||||
paint.ColorOp{Color: s.Colors[chn]}.Add(gtx.Ops)
|
||||
}
|
||||
clippedColorSet = clipped
|
||||
}
|
||||
stack := clip.Rect{Min: image.Pt(px, y1), Max: image.Pt(px+1, y2+1)}.Push(gtx.Ops)
|
||||
paint.PaintOp{}.Add(gtx.Ops)
|
||||
stack.Pop()
|
||||
yprev = y
|
||||
}
|
||||
}
|
||||
return D{Size: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y)}
|
||||
}
|
||||
|
||||
func (o *OscilloscopeStyle) update(gtx C) {
|
||||
for {
|
||||
ev, ok := gtx.Event(pointer.Filter{
|
||||
Target: o.Oscilloscope,
|
||||
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)
|
||||
o.Oscilloscope.xScale += min(max(-1, int(e.Scroll.Y)), 1)
|
||||
s2 := o.pxToSample(gtx, e.Position.X)
|
||||
o.Oscilloscope.xOffset -= s1 - s2
|
||||
case pointer.Press:
|
||||
if e.Buttons&pointer.ButtonSecondary != 0 {
|
||||
o.Oscilloscope.xOffset = 0
|
||||
o.Oscilloscope.xScale = 0
|
||||
}
|
||||
if e.Buttons&pointer.ButtonPrimary != 0 {
|
||||
o.Oscilloscope.dragging = true
|
||||
o.Oscilloscope.dragId = e.PointerID
|
||||
o.Oscilloscope.dragStartPx = e.Position.X
|
||||
}
|
||||
case pointer.Drag:
|
||||
if e.Buttons&pointer.ButtonPrimary != 0 && o.Oscilloscope.dragging && e.PointerID == o.Oscilloscope.dragId {
|
||||
delta := o.pxToSample(gtx, e.Position.X) - o.pxToSample(gtx, o.Oscilloscope.dragStartPx)
|
||||
o.Oscilloscope.xOffset += delta
|
||||
o.Oscilloscope.dragStartPx = e.Position.X
|
||||
}
|
||||
case pointer.Release | pointer.Cancel:
|
||||
o.Oscilloscope.dragging = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (o *OscilloscopeStyle) scaleFactor() float32 {
|
||||
return float32(math.Pow(1.1, float64(o.Oscilloscope.xScale)))
|
||||
}
|
||||
|
||||
func (s *OscilloscopeStyle) pxToSample(gtx C, px float32) float32 {
|
||||
return px*s.scaleFactor()*float32(len(s.Wave.Buffer))/float32(gtx.Constraints.Max.X) - s.Oscilloscope.xOffset
|
||||
}
|
||||
|
||||
func (s *OscilloscopeStyle) sampleToPx(gtx C, sample float32) float32 {
|
||||
return (sample + s.Oscilloscope.xOffset) * float32(gtx.Constraints.Max.X) / float32(len(s.Wave.Buffer)) / s.scaleFactor()
|
||||
}
|
@ -29,6 +29,8 @@ type SongPanel struct {
|
||||
PanicBtn *BoolClickable
|
||||
LoopBtn *BoolClickable
|
||||
|
||||
Scope *Oscilloscope
|
||||
|
||||
// File menu items
|
||||
fileMenuItems []MenuItem
|
||||
NewSong tracker.Action
|
||||
@ -68,6 +70,7 @@ func NewSongPanel(model *tracker.Model) *SongPanel {
|
||||
FollowBtn: NewBoolClickable(model.Follow().Bool()),
|
||||
PlayingBtn: NewBoolClickable(model.Playing().Bool()),
|
||||
RewindBtn: NewActionClickable(model.PlaySongStart()),
|
||||
Scope: NewOscilloscope(model),
|
||||
}
|
||||
ret.fileMenuItems = []MenuItem{
|
||||
{IconBytes: icons.ContentClear, Text: "New Song", ShortcutText: keyActionMap["NewSong"], Doer: model.NewSong()},
|
||||
@ -120,9 +123,16 @@ func (t *SongPanel) layoutMenuBar(gtx C, tr *Tracker) D {
|
||||
gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(36))
|
||||
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(36))
|
||||
|
||||
panicBtnStyle := ToggleIcon(gtx, tr.Theme, t.PanicBtn, icons.AlertErrorOutline, icons.AlertError, t.panicHint, t.panicHint)
|
||||
if t.PanicBtn.Bool.Value() {
|
||||
panicBtnStyle.IconButtonStyle.Color = errorColor
|
||||
}
|
||||
menuLayouts := []layout.FlexChild{
|
||||
layout.Rigid(tr.layoutMenu(gtx, "File", &t.MenuBar[0], &t.Menus[0], unit.Dp(200), t.fileMenuItems...)),
|
||||
layout.Rigid(tr.layoutMenu(gtx, "Edit", &t.MenuBar[1], &t.Menus[1], unit.Dp(200), t.editMenuItems...)),
|
||||
layout.Flexed(1, func(gtx C) D {
|
||||
return layout.E.Layout(gtx, panicBtnStyle.Layout)
|
||||
}),
|
||||
}
|
||||
if len(t.midiMenuItems) > 0 {
|
||||
menuLayouts = append(
|
||||
@ -138,13 +148,14 @@ func (t *SongPanel) layoutSongOptions(gtx C, tr *Tracker) D {
|
||||
|
||||
in := layout.UniformInset(unit.Dp(1))
|
||||
|
||||
panicBtnStyle := ToggleButton(gtx, tr.Theme, t.PanicBtn, t.panicHint)
|
||||
rewindBtnStyle := ActionIcon(gtx, tr.Theme, t.RewindBtn, icons.AVFastRewind, t.rewindHint)
|
||||
playBtnStyle := ToggleIcon(gtx, tr.Theme, t.PlayingBtn, icons.AVPlayArrow, icons.AVStop, t.playHint, t.stopHint)
|
||||
recordBtnStyle := ToggleIcon(gtx, tr.Theme, t.RecordBtn, icons.AVFiberManualRecord, icons.AVFiberSmartRecord, t.recordHint, t.stopRecordHint)
|
||||
noteTrackBtnStyle := ToggleIcon(gtx, tr.Theme, t.FollowBtn, icons.ActionSpeakerNotesOff, icons.ActionSpeakerNotes, t.followOffHint, t.followOnHint)
|
||||
loopBtnStyle := ToggleIcon(gtx, tr.Theme, t.LoopBtn, icons.NavigationArrowForward, icons.AVLoop, t.loopOffHint, t.loopOnHint)
|
||||
|
||||
scopeStyle := LineOscilloscope(t.Scope, tr.SignalAnalyzer().Waveform(), tr.Theme)
|
||||
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||
@ -205,7 +216,7 @@ func (t *SongPanel) layoutSongOptions(gtx C, tr *Tracker) D {
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(VuMeter{AverageVolume: tr.Model.AverageVolume(), PeakVolume: tr.Model.PeakVolume(), Range: 100}.Layout),
|
||||
layout.Rigid(VuMeter{Loudness: tr.Model.DetectorResult().Loudness[tracker.LoudnessShortTerm], Peak: tr.Model.DetectorResult().Peaks[tracker.PeakMomentary], Range: 100}.Layout),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(rewindBtnStyle.Layout),
|
||||
@ -215,8 +226,7 @@ func (t *SongPanel) layoutSongOptions(gtx C, tr *Tracker) D {
|
||||
layout.Rigid(loopBtnStyle.Layout),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(panicBtnStyle.Layout),
|
||||
layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }),
|
||||
layout.Flexed(1, scopeStyle.Layout),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
labelStyle := LabelStyle{Text: version.VersionOrHash, FontSize: unit.Sp(12), Color: mediumEmphasisTextColor, Shaper: tr.Theme.Shaper}
|
||||
return labelStyle.Layout(gtx)
|
||||
|
@ -116,8 +116,8 @@ func (t *Tracker) Main() {
|
||||
var ops op.Ops
|
||||
for {
|
||||
select {
|
||||
case e := <-t.PlayerMessages:
|
||||
t.ProcessPlayerMessage(e)
|
||||
case e := <-t.Broker().ToModel:
|
||||
t.ProcessMsg(e)
|
||||
w.Invalidate()
|
||||
case e := <-events:
|
||||
switch e := e.(type) {
|
||||
@ -166,6 +166,7 @@ func (t *Tracker) Main() {
|
||||
w.Perform(system.ActionClose)
|
||||
t.SaveRecovery()
|
||||
t.quitWG.Done()
|
||||
t.Broker().Close()
|
||||
}
|
||||
|
||||
func eventLoop(w *app.Window, events chan<- event.Event, acks <-chan struct{}) {
|
||||
|
@ -11,9 +11,9 @@ import (
|
||||
)
|
||||
|
||||
type VuMeter struct {
|
||||
AverageVolume tracker.Volume
|
||||
PeakVolume tracker.Volume
|
||||
Range float32
|
||||
Loudness tracker.Decibel
|
||||
Peak [2]tracker.Decibel
|
||||
Range float32
|
||||
}
|
||||
|
||||
func (v VuMeter) Layout(gtx C) D {
|
||||
@ -21,7 +21,7 @@ func (v VuMeter) Layout(gtx C) D {
|
||||
gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(12))
|
||||
height := gtx.Dp(unit.Dp(6))
|
||||
for j := 0; j < 2; j++ {
|
||||
value := float32(v.AverageVolume[j]) + v.Range
|
||||
value := float32(v.Loudness) + v.Range
|
||||
if value > 0 {
|
||||
x := int(value/v.Range*float32(gtx.Constraints.Max.X) + 0.5)
|
||||
if x > gtx.Constraints.Max.X {
|
||||
@ -29,7 +29,7 @@ func (v VuMeter) Layout(gtx C) D {
|
||||
}
|
||||
paint.FillShape(gtx.Ops, mediumEmphasisTextColor, clip.Rect(image.Rect(0, 0, x, height)).Op())
|
||||
}
|
||||
valueMax := float32(v.PeakVolume[j]) + v.Range
|
||||
valueMax := float32(v.Peak[j]) + v.Range
|
||||
if valueMax > 0 {
|
||||
color := white
|
||||
if valueMax >= v.Range {
|
||||
|
Reference in New Issue
Block a user