From 1b5cf118e1fac958f19f1db01db5b0ac53393e03 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Fri, 19 Jul 2024 16:52:08 -0700 Subject: [PATCH 1/7] WIP: peak/rms meter visualization --- backend/player/mpv/player.go | 62 +++++++++++---- main.go | 8 ++ ui/peakmeter.go | 141 +++++++++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 14 deletions(-) create mode 100644 ui/peakmeter.go diff --git a/backend/player/mpv/player.go b/backend/player/mpv/player.go index 41a31b6..1a6999b 100644 --- a/backend/player/mpv/player.go +++ b/backend/player/mpv/player.go @@ -63,6 +63,7 @@ type Player struct { prePausedState player.State clientName string equalizer Equalizer + peaksEnabled bool bgCancel context.CancelFunc @@ -126,6 +127,7 @@ func (p *Player) Init(maxCacheMB int) error { if err := m.Initialize(); err != nil { return fmt.Errorf("error initializing mpv: %s", err.Error()) } + p.mpv = m } ctx, cancel := context.WithCancel(context.Background()) @@ -355,20 +357,7 @@ func (p *Player) SetAudioDevice(deviceName string) error { func (p *Player) SetEqualizer(eq Equalizer) error { p.equalizer = eq - if eq == nil || !eq.IsEnabled() { - return p.mpv.SetPropertyString("af", "") - } - af := "" - if math.Abs(eq.Preamp()) > 0.01 { - af = fmt.Sprintf("volume=volume=%0.1fdB", eq.Preamp()) - } - eqAF := eq.Curve().String() - if af == "" { - af = eqAF - } else if eqAF != "" { - af = fmt.Sprintf("%s,%s", af, eqAF) - } - return p.mpv.SetPropertyString("af", af) + return p.setAF() } func (p *Player) Equalizer() Equalizer { @@ -453,6 +442,31 @@ func (p *Player) Destroy() { } } +func (p *Player) SetPeaksEnabled(enabled bool) error { + if p.peaksEnabled == enabled { + return nil + } + p.peaksEnabled = enabled + return p.setAF() +} + +func (p *Player) GetPeaks() (float64, float64, float64, float64) { + nInf := math.Inf(-1) + if p.status.State != player.Playing { + return nInf, nInf, nInf, nInf + } + prop, err := p.mpv.GetProperty("af-metadata/astats", mpv.FORMAT_NODE) + if err != nil { + return nInf, nInf, nInf, nInf + } + m := prop.(*mpv.Node).Data.(map[string]*mpv.Node) + lPeak, _ := strconv.ParseFloat(m["lavfi.astats.1.Peak_level"].Data.(string), 64) + rPeak, _ := strconv.ParseFloat(m["lavfi.astats.2.Peak_level"].Data.(string), 64) + lRMS, _ := strconv.ParseFloat(m["lavfi.astats.1.RMS_level"].Data.(string), 64) + rRMS, _ := strconv.ParseFloat(m["lavfi.astats.2.RMS_level"].Data.(string), 64) + return lPeak, rPeak, lRMS, rRMS +} + // sets the state and invokes callbacks, if triggered func (p *Player) setState(s player.State) { switch { @@ -478,6 +492,26 @@ func (p *Player) setState(s player.State) { p.status.State = s } +func (p *Player) setAF() error { + af := "" + if p.peaksEnabled { + af = "@astats:astats=metadata=1:reset=1" + } + eq := p.equalizer + if eq == nil || !eq.IsEnabled() { + return p.mpv.SetPropertyString("af", af) + } else if p.peaksEnabled { + af = af + "," + } + if math.Abs(eq.Preamp()) > 0.01 { + af = fmt.Sprintf("%svolume=volume=%0.1fdB", af, eq.Preamp()) + } + if eqAF := eq.Curve().String(); eqAF != "" { + af = fmt.Sprintf("%s,%s", af, eqAF) + } + return p.mpv.SetPropertyString("af", af) +} + func (p *Player) eventHandler(ctx context.Context) { for { select { diff --git a/main.go b/main.go index 07dda4b..40371ac 100644 --- a/main.go +++ b/main.go @@ -75,6 +75,14 @@ func main() { }() + myApp.LocalPlayer.SetPeaksEnabled(true) + + peaks := fyneApp.NewWindow("Peak meter") + meter := ui.NewPeakMeter(myApp.LocalPlayer.GetPeaks) + peaks.SetContent(meter) + meter.Start() + peaks.Show() + mainWindow.ShowAndRun() log.Println("Running shutdown tasks...") diff --git a/ui/peakmeter.go b/ui/peakmeter.go new file mode 100644 index 0000000..ac9b327 --- /dev/null +++ b/ui/peakmeter.go @@ -0,0 +1,141 @@ +package ui + +import ( + "image/color" + "math" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" +) + +const ( + meterRangeDB = 60 + rmsSmoothingFactor = 0.8 + peakHoldFrames = 60 +) + +type PeakFN func() (float64, float64, float64, float64) + +type PeakMeter struct { + widget.BaseWidget + peakFnDB PeakFN + lPeak float64 + rPeak float64 + lRMS float64 + rRMS float64 + lPeakHold float64 + rPeakHold float64 + lPeakHoldFrame uint64 + rPeakHoldFrame uint64 + frameCounter uint64 + + lPeakRect canvas.Rectangle + rPeakRect canvas.Rectangle + lRMSRect canvas.Rectangle + rRMSRect canvas.Rectangle + lPeakHoldRect canvas.Rectangle + rPeakHoldRect canvas.Rectangle + anim *fyne.Animation +} + +func NewPeakMeter(peakFnDB PeakFN) *PeakMeter { + p := &PeakMeter{peakFnDB: peakFnDB} + p.ExtendBaseWidget(p) + return p +} + +func (p *PeakMeter) Start() { + if p.anim != nil { + return + } + p.anim = fyne.NewAnimation(time.Duration(math.MaxInt64) /*until stopped*/, p.tick) + p.anim.Start() +} + +func (p *PeakMeter) Stop() { + if p.anim != nil { + p.anim.Stop() + p.anim = nil + p.frameCounter = 0 + p.lPeakHoldFrame = 0 + p.rPeakHoldFrame = 0 + } +} + +func (p *PeakMeter) tick(_ float32) { + lPeak, rPeak, lRMS, rRMS := p.peakFnDB() + p.lPeak = lPeak + p.rPeak = rPeak + lRMS = math.Max(-96, lRMS) + rRMS = math.Max(-96, rRMS) + p.lRMS = rmsSmoothingFactor*p.lRMS + (1-rmsSmoothingFactor)*lRMS + p.rRMS = rmsSmoothingFactor*p.rRMS + (1-rmsSmoothingFactor)*rRMS + + if lPeak > p.lPeakHold || p.frameCounter-p.lPeakHoldFrame > peakHoldFrames { + p.lPeakHold = lPeak + p.lPeakHoldFrame = p.frameCounter + } + if rPeak > p.rPeakHold || p.frameCounter-p.rPeakHoldFrame > peakHoldFrames { + p.rPeakHold = rPeak + p.rPeakHoldFrame = p.frameCounter + } + + p.frameCounter++ + p.Refresh() +} + +func (p *PeakMeter) CreateRenderer() fyne.WidgetRenderer { + c := theme.PrimaryColor().(color.NRGBA) + c.A = 128 + p.lPeakRect.FillColor = c + p.rPeakRect.FillColor = c + p.lRMSRect.FillColor = c + p.rRMSRect.FillColor = c + p.lPeakHoldRect.FillColor = theme.ForegroundColor() + p.rPeakHoldRect.FillColor = p.lPeakHoldRect.FillColor + return widget.NewSimpleRenderer( + container.New( + &peakMeterLayout{p}, + &p.lPeakRect, &p.rPeakRect, + &p.lRMSRect, &p.rRMSRect, + &p.lPeakHoldRect, &p.rPeakHoldRect, + ), + ) +} + +type peakMeterLayout struct { + p *PeakMeter +} + +func (l *peakMeterLayout) MinSize(_ []fyne.CanvasObject) fyne.Size { + return fyne.NewSize(50, 150) +} + +func (l *peakMeterLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { + lPeakH := float32(math.Max(0, meterRangeDB+l.p.lPeak)/meterRangeDB) * size.Height + rPeakH := float32(math.Max(0, meterRangeDB+l.p.rPeak)/meterRangeDB) * size.Height + lRMSH := float32(math.Max(0, meterRangeDB+l.p.lRMS)/meterRangeDB) * size.Height + rRMSH := float32(math.Max(0, meterRangeDB+l.p.rRMS)/meterRangeDB) * size.Height + lPeakHoldPos := float32(math.Max(0, meterRangeDB+l.p.lPeakHold)/meterRangeDB) * size.Height + rPeakHoldPos := float32(math.Max(0, meterRangeDB+l.p.rPeakHold)/meterRangeDB) * size.Height + + halfW := size.Width / 2 + objects[0].Move(fyne.NewPos(0, size.Height-lPeakH)) + objects[0].Resize(fyne.NewSize(halfW, lPeakH)) + objects[1].Move(fyne.NewPos(halfW, size.Height-rPeakH)) + objects[1].Resize(fyne.NewSize(halfW, rPeakH)) + objects[2].Move(fyne.NewPos(0, size.Height-lRMSH)) + objects[2].Resize(fyne.NewSize(halfW, lRMSH)) + objects[3].Move(fyne.NewPos(halfW, size.Height-rRMSH)) + objects[3].Resize(fyne.NewSize(halfW, rRMSH)) + + peakHoldHt := theme.SeparatorThicknessSize() * 2 + objects[4].Move(fyne.NewPos(0, size.Height-lPeakHoldPos-peakHoldHt)) + objects[4].Resize(fyne.NewSize(halfW, peakHoldHt)) + objects[5].Move(fyne.NewPos(halfW, size.Height-rPeakHoldPos-peakHoldHt)) + objects[5].Resize(fyne.NewSize(halfW, peakHoldHt)) +} From 0b3687a2ff273a90b78cba7f65c2f7ad468c11f1 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Mon, 22 Jul 2024 17:47:09 -0700 Subject: [PATCH 2/7] make peak meter horizontal, add ruler and L/R labels --- ui/peakmeter.go | 198 ++++++++++++++++++++++++++++++++++------------ ui/theme/theme.go | 4 +- 2 files changed, 148 insertions(+), 54 deletions(-) diff --git a/ui/peakmeter.go b/ui/peakmeter.go index ac9b327..524811c 100644 --- a/ui/peakmeter.go +++ b/ui/peakmeter.go @@ -1,19 +1,21 @@ package ui import ( + "fmt" "image/color" "math" "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" - "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" + + myTheme "github.com/dweymouth/supersonic/ui/theme" ) const ( - meterRangeDB = 60 + meterRangeDB = 62 rmsSmoothingFactor = 0.8 peakHoldFrames = 60 ) @@ -33,13 +35,7 @@ type PeakMeter struct { rPeakHoldFrame uint64 frameCounter uint64 - lPeakRect canvas.Rectangle - rPeakRect canvas.Rectangle - lRMSRect canvas.Rectangle - rRMSRect canvas.Rectangle - lPeakHoldRect canvas.Rectangle - rPeakHoldRect canvas.Rectangle - anim *fyne.Animation + anim *fyne.Animation } func NewPeakMeter(peakFnDB PeakFN) *PeakMeter { @@ -89,53 +85,151 @@ func (p *PeakMeter) tick(_ float32) { } func (p *PeakMeter) CreateRenderer() fyne.WidgetRenderer { + return newPeakMeterRenderer(p) +} + +type peakMeterRenderer struct { + p *PeakMeter + + lLabel canvas.Text + rLabel canvas.Text + lPeakRect canvas.Rectangle + rPeakRect canvas.Rectangle + lRMSRect canvas.Rectangle + rRMSRect canvas.Rectangle + lPeakHoldRect canvas.Rectangle + rPeakHoldRect canvas.Rectangle + + rulerLines []canvas.Rectangle + rulerLabels []canvas.Text + + fgColor color.Color + bgColor color.Color + ruleColor color.Color +} + +func newPeakMeterRenderer(pm *PeakMeter) *peakMeterRenderer { + p := &peakMeterRenderer{p: pm} + p.lLabel.Text = "L" + p.rLabel.Text = "R" + numRules := int(math.Ceil(float64(meterRangeDB) / 10)) + p.rulerLines = make([]canvas.Rectangle, numRules) + p.rulerLabels = make([]canvas.Text, numRules) + x := 0 + for i := range p.rulerLabels { + p.rulerLabels[i].Text = fmt.Sprintf("%d dB", x) + p.rulerLabels[i].Resize(p.rulerLabels[i].MinSize()) + x -= 10 + } + + return p +} + +func (l *peakMeterRenderer) MinSize() fyne.Size { + return fyne.NewSize(275, 75) +} + +func (l *peakMeterRenderer) Layout(size fyne.Size) { + topSpacing := float32(5) + lrLabelWidth := float32(20) + overflowWidth := float32(10) + ruleLabelHeight := float32(10) + meterWidth := size.Width - lrLabelWidth - overflowWidth - topSpacing + + lPeakWidth := float32(math.Max(0, meterRangeDB+l.p.lPeak)/meterRangeDB) * meterWidth + rPeakWidth := float32(math.Max(0, meterRangeDB+l.p.rPeak)/meterRangeDB) * meterWidth + lRMSWidth := float32(math.Max(0, meterRangeDB+l.p.lRMS)/meterRangeDB) * meterWidth + rRMSWidth := float32(math.Max(0, meterRangeDB+l.p.rRMS)/meterRangeDB) * meterWidth + lPeakHoldPos := float32(math.Max(0, meterRangeDB+l.p.lPeakHold)/meterRangeDB) * meterWidth + rPeakHoldPos := float32(math.Max(0, meterRangeDB+l.p.rPeakHold)/meterRangeDB) * meterWidth + + barSpacing := float32(2) + barHeight := size.Height/2 - barSpacing - ruleLabelHeight + + labelMin := l.lLabel.MinSize() + l.lLabel.Move(fyne.NewPos(4, (barHeight-labelMin.Height)/2+topSpacing)) + l.lLabel.Resize(l.lLabel.MinSize()) + l.rLabel.Move(fyne.NewPos(4, barHeight+barSpacing+topSpacing+(barHeight-labelMin.Height)/2)) + + l.lPeakRect.Move(fyne.NewPos(lrLabelWidth, topSpacing)) + l.lPeakRect.Resize(fyne.NewSize(lPeakWidth, barHeight)) + l.rPeakRect.Move(fyne.NewPos(lrLabelWidth, barHeight+barSpacing+topSpacing)) + l.rPeakRect.Resize(fyne.NewSize(rPeakWidth, barHeight)) + l.lRMSRect.Move(fyne.NewPos(lrLabelWidth, topSpacing)) + l.lRMSRect.Resize(fyne.NewSize(lRMSWidth, barHeight)) + l.rRMSRect.Move(fyne.NewPos(lrLabelWidth, barHeight+barSpacing+topSpacing)) + l.rRMSRect.Resize(fyne.NewSize(rRMSWidth, barHeight)) + + peakHoldWidth := theme.SeparatorThicknessSize() * 2 + l.lPeakHoldRect.Move(fyne.NewPos(lPeakHoldPos+lrLabelWidth, topSpacing)) + l.lPeakHoldRect.Resize(fyne.NewSize(peakHoldWidth, barHeight)) + l.rPeakHoldRect.Move(fyne.NewPos(rPeakHoldPos+lrLabelWidth, barHeight+barSpacing+topSpacing)) + l.rPeakHoldRect.Resize(fyne.NewSize(peakHoldWidth, barHeight)) + + ruleWidth := peakHoldWidth * 0.667 + x := lrLabelWidth + meterWidth + for i := range l.rulerLines { + bottom := (barHeight + barSpacing) * 2 + l.rulerLines[i].Move(fyne.NewPos(x, topSpacing)) + l.rulerLines[i].Resize(fyne.NewSize(ruleWidth, bottom)) + l.rulerLabels[i].Move(fyne.NewPos(x-10, bottom+topSpacing)) + x -= meterWidth * (10 / float32(meterRangeDB)) + } +} + +func (l *peakMeterRenderer) Refresh() { + foreground := theme.ForegroundColor() + background := theme.BackgroundColor() + errC := theme.ErrorColor() c := theme.PrimaryColor().(color.NRGBA) c.A = 128 - p.lPeakRect.FillColor = c - p.rPeakRect.FillColor = c - p.lRMSRect.FillColor = c - p.rRMSRect.FillColor = c - p.lPeakHoldRect.FillColor = theme.ForegroundColor() - p.rPeakHoldRect.FillColor = p.lPeakHoldRect.FillColor - return widget.NewSimpleRenderer( - container.New( - &peakMeterLayout{p}, - &p.lPeakRect, &p.rPeakRect, - &p.lRMSRect, &p.rRMSRect, - &p.lPeakHoldRect, &p.rPeakHoldRect, - ), - ) + l.lLabel.Color = foreground + l.rLabel.Color = foreground + l.lLabel.TextSize = 16 + l.rLabel.TextSize = 16 + l.lLabel.TextStyle.Bold = true + l.rLabel.TextStyle.Bold = true + l.lPeakRect.FillColor = c + l.rPeakRect.FillColor = c + l.lRMSRect.FillColor = c + l.rRMSRect.FillColor = c + + if l.p.lPeakHold >= -0.00001 { + l.lPeakHoldRect.FillColor = errC + } else { + l.lPeakHoldRect.FillColor = foreground + } + if l.p.rPeakHold >= -0.00001 { + l.rPeakHoldRect.FillColor = errC + } else { + l.rPeakHoldRect.FillColor = foreground + } + + if foreground != l.fgColor || background != l.bgColor { + l.ruleColor = myTheme.BlendColors(foreground, background, 0.5) + l.fgColor = foreground + l.bgColor = background + } + for i := range l.rulerLines { + l.rulerLines[i].FillColor = l.ruleColor + l.rulerLabels[i].TextSize = 11 + } + + l.Layout(l.p.Size()) } -type peakMeterLayout struct { - p *PeakMeter +func (l *peakMeterRenderer) Objects() []fyne.CanvasObject { + obj := make([]fyne.CanvasObject, 0, 6+len(l.rulerLines)) + for i := range l.rulerLines { + obj = append(obj, &l.rulerLines[i], &l.rulerLabels[i]) + } + return append(obj, + &l.lLabel, &l.rLabel, + &l.lPeakRect, &l.rPeakRect, + &l.lRMSRect, &l.rRMSRect, + &l.lPeakHoldRect, &l.rPeakHoldRect) } -func (l *peakMeterLayout) MinSize(_ []fyne.CanvasObject) fyne.Size { - return fyne.NewSize(50, 150) -} - -func (l *peakMeterLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { - lPeakH := float32(math.Max(0, meterRangeDB+l.p.lPeak)/meterRangeDB) * size.Height - rPeakH := float32(math.Max(0, meterRangeDB+l.p.rPeak)/meterRangeDB) * size.Height - lRMSH := float32(math.Max(0, meterRangeDB+l.p.lRMS)/meterRangeDB) * size.Height - rRMSH := float32(math.Max(0, meterRangeDB+l.p.rRMS)/meterRangeDB) * size.Height - lPeakHoldPos := float32(math.Max(0, meterRangeDB+l.p.lPeakHold)/meterRangeDB) * size.Height - rPeakHoldPos := float32(math.Max(0, meterRangeDB+l.p.rPeakHold)/meterRangeDB) * size.Height - - halfW := size.Width / 2 - objects[0].Move(fyne.NewPos(0, size.Height-lPeakH)) - objects[0].Resize(fyne.NewSize(halfW, lPeakH)) - objects[1].Move(fyne.NewPos(halfW, size.Height-rPeakH)) - objects[1].Resize(fyne.NewSize(halfW, rPeakH)) - objects[2].Move(fyne.NewPos(0, size.Height-lRMSH)) - objects[2].Resize(fyne.NewSize(halfW, lRMSH)) - objects[3].Move(fyne.NewPos(halfW, size.Height-rRMSH)) - objects[3].Resize(fyne.NewSize(halfW, rRMSH)) - - peakHoldHt := theme.SeparatorThicknessSize() * 2 - objects[4].Move(fyne.NewPos(0, size.Height-lPeakHoldPos-peakHoldHt)) - objects[4].Resize(fyne.NewSize(halfW, peakHoldHt)) - objects[5].Move(fyne.NewPos(halfW, size.Height-rPeakHoldPos-peakHoldHt)) - objects[5].Resize(fyne.NewSize(halfW, peakHoldHt)) +func (l *peakMeterRenderer) Destroy() { + l.p.Stop() } diff --git a/ui/theme/theme.go b/ui/theme/theme.go index 33d38bb..3727bb6 100644 --- a/ui/theme/theme.go +++ b/ui/theme/theme.go @@ -117,7 +117,7 @@ func (m *MyTheme) Color(name fyne.ThemeColorName, _ fyne.ThemeVariant) color.Col // average the Foreground and Disabled colors foreground := colorOrDefault(colors.Foreground, defColors.Foreground, theme.ColorNameForeground, variant) disabled := colorOrDefault(colors.Disabled, defColors.Disabled, theme.ColorNameDisabled, variant) - return blendColors(foreground, disabled, 0.33) + return BlendColors(foreground, disabled, 0.33) case ColorNameHoveredIconButton: if variant == theme.VariantDark { return color.White @@ -273,7 +273,7 @@ func (m *MyTheme) getVariant() fyne.ThemeVariant { return fyne.CurrentApp().Settings().ThemeVariant() } -func blendColors(a, b color.Color, fractionA float64) color.Color { +func BlendColors(a, b color.Color, fractionA float64) color.Color { ra, ga, ba, aa := a.RGBA() rb, gb, bb, ab := b.RGBA() From 9d0b381572654b44134485cabcc7b2c57fe2a9ed Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Mon, 22 Jul 2024 18:16:18 -0700 Subject: [PATCH 3/7] add controller logic for visualizations and add to gear menu --- backend/player/mpv/player.go | 13 ++++-- main.go | 9 ---- ui/controller/controller.go | 1 + ui/controller/visualizations.go | 68 ++++++++++++++++++++++++++++ ui/mainwindow.go | 3 ++ ui/{ => visualizations}/peakmeter.go | 37 +++------------ 6 files changed, 87 insertions(+), 44 deletions(-) create mode 100644 ui/controller/visualizations.go rename ui/{ => visualizations}/peakmeter.go (90%) diff --git a/backend/player/mpv/player.go b/backend/player/mpv/player.go index 1a6999b..a0b2c7e 100644 --- a/backend/player/mpv/player.go +++ b/backend/player/mpv/player.go @@ -460,11 +460,14 @@ func (p *Player) GetPeaks() (float64, float64, float64, float64) { return nInf, nInf, nInf, nInf } m := prop.(*mpv.Node).Data.(map[string]*mpv.Node) - lPeak, _ := strconv.ParseFloat(m["lavfi.astats.1.Peak_level"].Data.(string), 64) - rPeak, _ := strconv.ParseFloat(m["lavfi.astats.2.Peak_level"].Data.(string), 64) - lRMS, _ := strconv.ParseFloat(m["lavfi.astats.1.RMS_level"].Data.(string), 64) - rRMS, _ := strconv.ParseFloat(m["lavfi.astats.2.RMS_level"].Data.(string), 64) - return lPeak, rPeak, lRMS, rRMS + if lPeakNode, ok := m["lavfi.astats.1.Peak_level"]; ok { + lPeak, _ := strconv.ParseFloat(lPeakNode.Data.(string), 64) + rPeak, _ := strconv.ParseFloat(m["lavfi.astats.2.Peak_level"].Data.(string), 64) + lRMS, _ := strconv.ParseFloat(m["lavfi.astats.1.RMS_level"].Data.(string), 64) + rRMS, _ := strconv.ParseFloat(m["lavfi.astats.2.RMS_level"].Data.(string), 64) + return lPeak, rPeak, lRMS, rRMS + } + return nInf, nInf, nInf, nInf } // sets the state and invokes callbacks, if triggered diff --git a/main.go b/main.go index 40371ac..67cd2cb 100644 --- a/main.go +++ b/main.go @@ -74,15 +74,6 @@ func main() { } }() - - myApp.LocalPlayer.SetPeaksEnabled(true) - - peaks := fyneApp.NewWindow("Peak meter") - meter := ui.NewPeakMeter(myApp.LocalPlayer.GetPeaks) - peaks.SetContent(meter) - meter.Start() - peaks.Show() - mainWindow.ShowAndRun() log.Println("Running shutdown tasks...") diff --git a/ui/controller/controller.go b/ui/controller/controller.go index d6f7a06..63e9f64 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -36,6 +36,7 @@ type NavigationHandler func(Route) type CurPageFunc func() Route type Controller struct { + visualizationData AppVersion string App *backend.App MainWindow fyne.Window diff --git a/ui/controller/visualizations.go b/ui/controller/visualizations.go new file mode 100644 index 0000000..3ff12d6 --- /dev/null +++ b/ui/controller/visualizations.go @@ -0,0 +1,68 @@ +package controller + +import ( + "math" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/lang" + "github.com/dweymouth/supersonic/ui/visualizations" +) + +// embedded in parent controller struct +type visualizationData struct { + peakMeter *visualizations.PeakMeter + + visualizationAnim *fyne.Animation +} + +func (c *Controller) InitVisualizations() { + c.App.LocalPlayer.OnStopped(c.stopVisualizationAnim) + c.App.LocalPlayer.OnPaused(c.stopVisualizationAnim) + c.App.LocalPlayer.OnPlaying(func() { + if c.peakMeter != nil { + c.startVisualizationAnim() + } + }) +} + +func (c *Controller) ShowPeakMeter() { + if c.peakMeter != nil { + return + } + win := fyne.CurrentApp().NewWindow(lang.L("Peak Meter")) + win.SetCloseIntercept(func() { + c.stopVisualizationAnim() + c.peakMeter = nil + win.Close() + }) + c.peakMeter = visualizations.NewPeakMeter() + win.SetContent(c.peakMeter) + c.startVisualizationAnim() + win.Show() +} + +func (c *Controller) stopVisualizationAnim() { + if c.visualizationAnim != nil { + c.visualizationAnim.Stop() + c.visualizationAnim = nil + c.App.LocalPlayer.SetPeaksEnabled(false) + } +} + +func (c *Controller) startVisualizationAnim() { + if c.visualizationAnim == nil { + c.App.LocalPlayer.SetPeaksEnabled(true) + c.visualizationAnim = fyne.NewAnimation( + time.Duration(math.MaxInt64), /*until stopped*/ + c.tickVisualizations) + c.visualizationAnim.Start() + } +} + +func (c *Controller) tickVisualizations(_ float32) { + lP, rP, lRMS, rRMS := c.App.LocalPlayer.GetPeaks() + if c.visualizationData.peakMeter != nil { + c.visualizationData.peakMeter.UpdatePeaks(lP, rP, lRMS, rRMS) + } +} diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 4dbfe84..9b1fbef 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -21,6 +21,7 @@ import ( "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/lang" "fyne.io/fyne/v2/widget" ) @@ -80,6 +81,7 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, MainWindow: m.Window, App: app, } + m.Controller.InitVisualizations() m.BrowsingPane = browsing.NewBrowsingPane(app, m.Controller, func() { m.Router.NavigateTo(m.StartupPage()) }) m.Router = browsing.NewRouter(app, m.Controller, m.BrowsingPane) // inject controller dependencies @@ -136,6 +138,7 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, m.BrowsingPane.AddSettingsMenuItem("Switch Servers", func() { app.ServerManager.Logout(false) }) m.BrowsingPane.AddSettingsMenuItem("Rescan Library", func() { app.ServerManager.Server.RescanLibrary() }) m.BrowsingPane.AddSettingsMenuSeparator() + m.BrowsingPane.AddSettingsMenuItem(lang.L("Show Peak Meter"), m.Controller.ShowPeakMeter) m.BrowsingPane.AddSettingsMenuItem("Check for Updates", func() { go func() { if t := app.UpdateChecker.CheckLatestVersionTag(); t != "" && t != app.VersionTag() { diff --git a/ui/peakmeter.go b/ui/visualizations/peakmeter.go similarity index 90% rename from ui/peakmeter.go rename to ui/visualizations/peakmeter.go index 524811c..9bf2ba8 100644 --- a/ui/peakmeter.go +++ b/ui/visualizations/peakmeter.go @@ -1,10 +1,9 @@ -package ui +package visualizations import ( "fmt" "image/color" "math" - "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" @@ -20,11 +19,8 @@ const ( peakHoldFrames = 60 ) -type PeakFN func() (float64, float64, float64, float64) - type PeakMeter struct { widget.BaseWidget - peakFnDB PeakFN lPeak float64 rPeak float64 lRMS float64 @@ -34,36 +30,18 @@ type PeakMeter struct { lPeakHoldFrame uint64 rPeakHoldFrame uint64 frameCounter uint64 - - anim *fyne.Animation } -func NewPeakMeter(peakFnDB PeakFN) *PeakMeter { - p := &PeakMeter{peakFnDB: peakFnDB} +func NewPeakMeter() *PeakMeter { + p := &PeakMeter{} p.ExtendBaseWidget(p) return p } -func (p *PeakMeter) Start() { - if p.anim != nil { - return - } - p.anim = fyne.NewAnimation(time.Duration(math.MaxInt64) /*until stopped*/, p.tick) - p.anim.Start() -} - -func (p *PeakMeter) Stop() { - if p.anim != nil { - p.anim.Stop() - p.anim = nil - p.frameCounter = 0 - p.lPeakHoldFrame = 0 - p.rPeakHoldFrame = 0 - } -} - -func (p *PeakMeter) tick(_ float32) { - lPeak, rPeak, lRMS, rRMS := p.peakFnDB() +// UpdatePeaks updates the peaks that are displayed in the meter. +// This function is expected to be called from a fyne.Animation callback, +// running at 60 Hz +func (p *PeakMeter) UpdatePeaks(lPeak, rPeak, lRMS, rRMS float64) { p.lPeak = lPeak p.rPeak = rPeak lRMS = math.Max(-96, lRMS) @@ -231,5 +209,4 @@ func (l *peakMeterRenderer) Objects() []fyne.CanvasObject { } func (l *peakMeterRenderer) Destroy() { - l.p.Stop() } From 4d635d88b91ceaff8ac6a933a19a422fc11f8f49 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Mon, 22 Jul 2024 19:04:59 -0700 Subject: [PATCH 4/7] save peak meter window size --- backend/config.go | 10 ++++++++++ ui/controller/visualizations.go | 9 +++++++++ ui/mainwindow.go | 9 ++++----- ui/util/util.go | 7 +++++++ 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/backend/config.go b/backend/config.go index 1cf2d14..cb36481 100644 --- a/backend/config.go +++ b/backend/config.go @@ -124,6 +124,11 @@ type TranscodingConfig struct { ForceRawFile bool } +type PeakMeterConfig struct { + WindowHeight int + WindowWidth int +} + type Config struct { Application AppConfig Servers []*ServerConfig @@ -141,6 +146,7 @@ type Config struct { ReplayGain ReplayGainConfig Transcoding TranscodingConfig Theme ThemeConfig + PeakMeter PeakMeterConfig } var SupportedStartupPages = []string{"Albums", "Favorites", "Playlists"} @@ -221,6 +227,10 @@ func DefaultConfig(appVersionTag string) *Config { Theme: ThemeConfig{ Appearance: "Dark", }, + PeakMeter: PeakMeterConfig{ + WindowWidth: 375, + WindowHeight: 100, + }, } } diff --git a/ui/controller/visualizations.go b/ui/controller/visualizations.go index 3ff12d6..15b0901 100644 --- a/ui/controller/visualizations.go +++ b/ui/controller/visualizations.go @@ -6,6 +6,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/lang" + "github.com/dweymouth/supersonic/ui/util" "github.com/dweymouth/supersonic/ui/visualizations" ) @@ -35,7 +36,15 @@ func (c *Controller) ShowPeakMeter() { c.stopVisualizationAnim() c.peakMeter = nil win.Close() + util.SaveWindowSize(win, + &c.App.Config.PeakMeter.WindowWidth, + &c.App.Config.PeakMeter.WindowHeight) }) + if c.App.Config.PeakMeter.WindowHeight > 0 { + win.Resize(fyne.NewSize( + float32(c.App.Config.PeakMeter.WindowWidth), + float32(c.App.Config.PeakMeter.WindowHeight))) + } c.peakMeter = visualizations.NewPeakMeter() win.SetContent(c.peakMeter) c.startVisualizationAnim() diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 9b1fbef..6c48f6d 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -3,7 +3,6 @@ package ui import ( "fmt" "log" - "math" "runtime" "strings" "time" @@ -16,6 +15,7 @@ import ( "github.com/dweymouth/supersonic/ui/dialogs" "github.com/dweymouth/supersonic/ui/os" "github.com/dweymouth/supersonic/ui/theme" + "github.com/dweymouth/supersonic/ui/util" "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" @@ -432,8 +432,7 @@ func (m *MainWindow) Quit() { } func (m *MainWindow) SaveWindowSize() { - // round sizes to even to avoid Wayland issues with 2x scaling factor - // https://github.com/dweymouth/supersonic/issues/212 - m.App.Config.Application.WindowHeight = int(math.RoundToEven(float64(m.Window.Canvas().Size().Height))) - m.App.Config.Application.WindowWidth = int(math.RoundToEven(float64(m.Window.Canvas().Size().Width))) + util.SaveWindowSize(m.Window, + &m.App.Config.Application.WindowWidth, + &m.App.Config.Application.WindowHeight) } diff --git a/ui/util/util.go b/ui/util/util.go index b479288..3dad8bf 100644 --- a/ui/util/util.go +++ b/ui/util/util.go @@ -257,6 +257,13 @@ func NewTrailingAlignLabel() *widget.Label { return rt } +func SaveWindowSize(w fyne.Window, wPtr, hPtr *int) { + // round sizes to even to avoid Wayland issues with 2x scaling factor + // https://github.com/dweymouth/supersonic/issues/212 + *wPtr = int(math.RoundToEven(float64(w.Canvas().Size().Width))) + *hPtr = int(math.RoundToEven(float64(w.Canvas().Size().Height))) +} + type HSpace struct { widget.BaseWidget From fdeb33f85790cf618562f789d999cb2622f8b0e1 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Mon, 22 Jul 2024 19:38:30 -0700 Subject: [PATCH 5/7] fix initial conditions --- ui/controller/visualizations.go | 8 +++++++- ui/visualizations/peakmeter.go | 16 +++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/ui/controller/visualizations.go b/ui/controller/visualizations.go index 15b0901..58b4ed3 100644 --- a/ui/controller/visualizations.go +++ b/ui/controller/visualizations.go @@ -6,6 +6,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/lang" + "github.com/dweymouth/supersonic/backend/player" "github.com/dweymouth/supersonic/ui/util" "github.com/dweymouth/supersonic/ui/visualizations" ) @@ -47,7 +48,12 @@ func (c *Controller) ShowPeakMeter() { } c.peakMeter = visualizations.NewPeakMeter() win.SetContent(c.peakMeter) - c.startVisualizationAnim() + if c.App.LocalPlayer.GetStatus().State == player.Playing { + c.startVisualizationAnim() + } else { + // TODO: why is this needed? + c.peakMeter.Refresh() + } win.Show() } diff --git a/ui/visualizations/peakmeter.go b/ui/visualizations/peakmeter.go index 9bf2ba8..57ecf2f 100644 --- a/ui/visualizations/peakmeter.go +++ b/ui/visualizations/peakmeter.go @@ -17,6 +17,8 @@ const ( meterRangeDB = 62 rmsSmoothingFactor = 0.8 peakHoldFrames = 60 + + noiseFloorDB = -96 ) type PeakMeter struct { @@ -33,7 +35,14 @@ type PeakMeter struct { } func NewPeakMeter() *PeakMeter { - p := &PeakMeter{} + p := &PeakMeter{ + lPeak: noiseFloorDB, + rPeak: noiseFloorDB, + lRMS: noiseFloorDB, + rRMS: noiseFloorDB, + lPeakHold: noiseFloorDB, + rPeakHold: noiseFloorDB, + } p.ExtendBaseWidget(p) return p } @@ -44,8 +53,8 @@ func NewPeakMeter() *PeakMeter { func (p *PeakMeter) UpdatePeaks(lPeak, rPeak, lRMS, rRMS float64) { p.lPeak = lPeak p.rPeak = rPeak - lRMS = math.Max(-96, lRMS) - rRMS = math.Max(-96, rRMS) + lRMS = math.Max(noiseFloorDB, lRMS) + rRMS = math.Max(noiseFloorDB, rRMS) p.lRMS = rmsSmoothingFactor*p.lRMS + (1-rmsSmoothingFactor)*lRMS p.rRMS = rmsSmoothingFactor*p.rRMS + (1-rmsSmoothingFactor)*rRMS @@ -99,6 +108,7 @@ func newPeakMeterRenderer(pm *PeakMeter) *peakMeterRenderer { p.rulerLabels[i].Resize(p.rulerLabels[i].MinSize()) x -= 10 } + p.Layout(pm.Size()) return p } From de1e0611b149c3248604164d85a3152e51361e67 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Mon, 22 Jul 2024 20:03:17 -0700 Subject: [PATCH 6/7] add translation entry --- res/translations/en.json | 1 + 1 file changed, 1 insertion(+) diff --git a/res/translations/en.json b/res/translations/en.json index 2fb5581..01a4cf5 100644 --- a/res/translations/en.json +++ b/res/translations/en.json @@ -105,6 +105,7 @@ "or when": "or when", "Password": "Password", "Paused": "Paused", + "Peak Meter": "Peak Meter", "percent of track is played": "percent of track is played", "Play": "Play", "Play Artist Radio": "Play Artist Radio", From b78b2353412ca19280fcfb69a505b6a11509332a Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Tue, 23 Jul 2024 08:24:50 -0700 Subject: [PATCH 7/7] tweaks and optimizations --- backend/player/mpv/player.go | 2 +- ui/browsing/browsingpane.go | 6 ++++++ ui/mainwindow.go | 5 ++++- ui/visualizations/peakmeter.go | 21 +++++++++++++-------- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/backend/player/mpv/player.go b/backend/player/mpv/player.go index a0b2c7e..5dfd52b 100644 --- a/backend/player/mpv/player.go +++ b/backend/player/mpv/player.go @@ -498,7 +498,7 @@ func (p *Player) setState(s player.State) { func (p *Player) setAF() error { af := "" if p.peaksEnabled { - af = "@astats:astats=metadata=1:reset=1" + af = "@astats:astats=metadata=1:reset=1:measure_overall=none" } eq := p.equalizer if eq == nil || !eq.IsEnabled() { diff --git a/ui/browsing/browsingpane.go b/ui/browsing/browsingpane.go index d2868d3..9c135be 100644 --- a/ui/browsing/browsingpane.go +++ b/ui/browsing/browsingpane.go @@ -135,6 +135,12 @@ func (b *BrowsingPane) AddSettingsMenuItem(label string, action func()) { fyne.NewMenuItem(label, action)) } +func (b *BrowsingPane) AddSettingsSubmenu(label string, menu *fyne.Menu) { + item := fyne.NewMenuItem(label, nil) + item.ChildMenu = menu + b.settingsMenu.Items = append(b.settingsMenu.Items, item) +} + func (b *BrowsingPane) AddSettingsMenuSeparator() { b.settingsMenu.Items = append(b.settingsMenu.Items, fyne.NewMenuItemSeparator()) diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 6c48f6d..c81ee8d 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -138,7 +138,10 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, m.BrowsingPane.AddSettingsMenuItem("Switch Servers", func() { app.ServerManager.Logout(false) }) m.BrowsingPane.AddSettingsMenuItem("Rescan Library", func() { app.ServerManager.Server.RescanLibrary() }) m.BrowsingPane.AddSettingsMenuSeparator() - m.BrowsingPane.AddSettingsMenuItem(lang.L("Show Peak Meter"), m.Controller.ShowPeakMeter) + m.BrowsingPane.AddSettingsSubmenu(lang.L("Visualizations"), + fyne.NewMenu("", []*fyne.MenuItem{ + fyne.NewMenuItem(lang.L("Peak Meter"), m.Controller.ShowPeakMeter), + }...)) m.BrowsingPane.AddSettingsMenuItem("Check for Updates", func() { go func() { if t := app.UpdateChecker.CheckLatestVersionTag(); t != "" && t != app.VersionTag() { diff --git a/ui/visualizations/peakmeter.go b/ui/visualizations/peakmeter.go index 57ecf2f..6528f12 100644 --- a/ui/visualizations/peakmeter.go +++ b/ui/visualizations/peakmeter.go @@ -90,6 +90,8 @@ type peakMeterRenderer struct { rulerLines []canvas.Rectangle rulerLabels []canvas.Text + objects []fyne.CanvasObject + fgColor color.Color bgColor color.Color ruleColor color.Color @@ -207,15 +209,18 @@ func (l *peakMeterRenderer) Refresh() { } func (l *peakMeterRenderer) Objects() []fyne.CanvasObject { - obj := make([]fyne.CanvasObject, 0, 6+len(l.rulerLines)) - for i := range l.rulerLines { - obj = append(obj, &l.rulerLines[i], &l.rulerLabels[i]) + if l.objects == nil { + l.objects = make([]fyne.CanvasObject, 0, 6+len(l.rulerLines)) + for i := range l.rulerLines { + l.objects = append(l.objects, &l.rulerLines[i], &l.rulerLabels[i]) + } + l.objects = append(l.objects, + &l.lLabel, &l.rLabel, + &l.lPeakRect, &l.rPeakRect, + &l.lRMSRect, &l.rRMSRect, + &l.lPeakHoldRect, &l.rPeakHoldRect) } - return append(obj, - &l.lLabel, &l.rLabel, - &l.lPeakRect, &l.rPeakRect, - &l.lRMSRect, &l.rRMSRect, - &l.lPeakHoldRect, &l.rPeakHoldRect) + return l.objects } func (l *peakMeterRenderer) Destroy() {