refactor(tracker): new closing mechanism logic

This commit is contained in:
5684185+vsariola@users.noreply.github.com 2025-04-30 22:42:35 +03:00
parent 9f89c37956
commit 554a840982
9 changed files with 172 additions and 137 deletions

View File

@ -8,6 +8,7 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"runtime/pprof" "runtime/pprof"
"time"
"gioui.org/app" "gioui.org/app"
"github.com/vsariola/sointu" "github.com/vsariola/sointu"
@ -71,7 +72,8 @@ func main() {
go func() { go func() {
trackerUi.Main() trackerUi.Main()
audioCloser.Close() audioCloser.Close()
detector.Close() tracker.TrySend(broker.CloseDetector, struct{}{})
tracker.TimeoutReceive(broker.FinishedDetector, 3*time.Second)
if *cpuprofile != "" { if *cpuprofile != "" {
pprof.StopCPUProfile() pprof.StopCPUProfile()
f.Close() f.Close()

View File

@ -9,6 +9,7 @@ import (
"math" "math"
"os" "os"
"path/filepath" "path/filepath"
"time"
"github.com/vsariola/sointu" "github.com/vsariola/sointu"
"github.com/vsariola/sointu/cmd" "github.com/vsariola/sointu/cmd"
@ -134,17 +135,22 @@ func init() {
} }
}, },
CloseFunc: func() { CloseFunc: func() {
broker.ToModel <- tracker.MsgToModel{Data: func() { t.ForceQuit().Do() }} tracker.TrySend(broker.CloseDetector, struct{}{})
t.WaitQuitted() tracker.TrySend(broker.CloseGUI, struct{}{})
detector.Close() tracker.TimeoutReceive(broker.FinishedDetector, 3*time.Second)
tracker.TimeoutReceive(broker.FinishedGUI, 3*time.Second)
}, },
GetChunkFunc: func(isPreset bool) []byte { GetChunkFunc: func(isPreset bool) []byte {
retChn := make(chan []byte) retChn := make(chan []byte)
broker.ToModel <- tracker.MsgToModel{Data: func() { retChn <- t.MarshalRecovery() }}
return <-retChn if !tracker.TrySend(broker.ToModel, tracker.MsgToModel{Data: func() { retChn <- t.MarshalRecovery() }}) {
return nil
}
ret, _ := tracker.TimeoutReceive(retChn, 5*time.Second) // ret will be nil if timeout or channel closed
return ret
}, },
SetChunkFunc: func(data []byte, isPreset bool) { SetChunkFunc: func(data []byte, isPreset bool) {
broker.ToModel <- tracker.MsgToModel{Data: func() { t.UnmarshalRecovery(data) }} tracker.TrySend(broker.ToModel, tracker.MsgToModel{Data: func() { t.UnmarshalRecovery(data) }})
}, },
} }

View File

@ -436,10 +436,12 @@ func (m *Model) OpenSong() Action {
}) })
} }
func (m *Model) Quit() Action { func (m *Model) RequestQuit() Action {
return Allow(func() { return Allow(func() {
m.dialog = QuitChanges if !m.quitted {
m.completeAction(true) m.dialog = QuitChanges
m.completeAction(true)
}
}) })
} }

View File

@ -2,6 +2,7 @@ package tracker
import ( import (
"sync" "sync"
"time"
"github.com/vsariola/sointu" "github.com/vsariola/sointu"
"github.com/vsariola/sointu/vm" "github.com/vsariola/sointu/vm"
@ -16,11 +17,33 @@ type (
// return buffers to pass buffers around without allocating new memory every // return buffers to pass buffers around without allocating new memory every
// time. We can later consider making many-to-many types of communication // time. We can later consider making many-to-many types of communication
// and more complex routing logic to the Broker if needed. // and more complex routing logic to the Broker if needed.
//
// For closing goroutines, the broker has two channels for each goroutine:
// CloseXXX and FinishedXXX. The CloseXXX channel has a capacity of 1, so
// you can always send a empty message (struct{}{}) to it without blocking.
// If the channel is already full, that means someone else has already
// requested its closure and the goroutine is already closing, so dropping
// the message is fine. Then, FinishedXXX is used to signal that a goroutine
// has succesfully closed and cleaned up. Nothing is ever sent to the
// channel, it is only closed. You can wait until the goroutines is done
// closing with "<- FinishedXXX", which for avoiding deadlocks can be
// combined with a timeout:
// select {
// case <-FinishedXXX:
// case <-time.After(3 * time.Second):
// }
Broker struct { Broker struct {
ToModel chan MsgToModel ToModel chan MsgToModel
ToPlayer chan any // TODO: consider using a sum type here, for a bit more type safety. See: https://www.jerf.org/iri/post/2917/ 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 ToDetector chan MsgToDetector
CloseDetector chan struct{}
CloseGUI chan struct{}
FinishedGUI chan struct{}
FinishedDetector chan struct{}
bufferPool sync.Pool bufferPool sync.Pool
} }
@ -49,7 +72,6 @@ type (
// which gets executed in the detector goroutine. // which gets executed in the detector goroutine.
MsgToDetector struct { MsgToDetector struct {
Reset bool Reset bool
Quit bool
Data any // TODO: consider using a sum type here, for a bit more type safety. See: https://www.jerf.org/iri/post/2917/ Data any // TODO: consider using a sum type here, for a bit more type safety. See: https://www.jerf.org/iri/post/2917/
WeightingType WeightingType WeightingType WeightingType
@ -61,10 +83,14 @@ type (
func NewBroker() *Broker { func NewBroker() *Broker {
return &Broker{ return &Broker{
ToPlayer: make(chan interface{}, 1024), ToPlayer: make(chan interface{}, 1024),
ToModel: make(chan MsgToModel, 1024), ToModel: make(chan MsgToModel, 1024),
ToDetector: make(chan MsgToDetector, 1024), ToDetector: make(chan MsgToDetector, 1024),
bufferPool: sync.Pool{New: func() interface{} { return &sointu.AudioBuffer{} }}, CloseDetector: make(chan struct{}, 1),
CloseGUI: make(chan struct{}, 1),
FinishedGUI: make(chan struct{}),
FinishedDetector: make(chan struct{}),
bufferPool: sync.Pool{New: func() interface{} { return &sointu.AudioBuffer{} }},
} }
} }
@ -96,3 +122,15 @@ func TrySend[T any](c chan<- T, v T) bool {
} }
return true return true
} }
// TimeoutReceive is a helper function to block until a value is received from a
// channel, or timing out after t. ok will be false if the timeout occurred or
// if the channel is closed.
func TimeoutReceive[T any](c <-chan T, t time.Duration) (v T, ok bool) {
select {
case v, ok = <-c:
return v, ok
case <-time.After(t):
return v, false
}
}

View File

@ -12,6 +12,7 @@ type (
broker *Broker broker *Broker
loudnessDetector loudnessDetector loudnessDetector loudnessDetector
peakDetector peakDetector peakDetector peakDetector
chunkHistory sointu.AudioBuffer
} }
WeightingType int WeightingType int
@ -98,65 +99,63 @@ func NewDetector(b *Broker) *Detector {
} }
func (s *Detector) Run() { func (s *Detector) Run() {
var chunkHistory sointu.AudioBuffer for {
for msg := range s.broker.ToDetector { select {
if msg.Reset { case <-s.broker.CloseDetector:
s.loudnessDetector.reset() close(s.broker.FinishedDetector)
s.peakDetector.reset()
}
if msg.Quit {
return return
} case msg := <-s.broker.ToDetector:
if msg.HasWeightingType { s.handleMsg(msg)
s.loudnessDetector.weighting = weightings[WeightingType(msg.WeightingType)]
s.loudnessDetector.reset()
}
if msg.HasOversampling {
s.peakDetector.oversampling = msg.Oversampling
s.peakDetector.reset()
}
switch data := msg.Data.(type) {
case *sointu.AudioBuffer:
buf := *data
for {
var chunk sointu.AudioBuffer
if len(chunkHistory) > 0 && len(chunkHistory) < 4410 {
l := min(len(buf), 4410-len(chunkHistory))
chunkHistory = append(chunkHistory, buf[:l]...)
if len(chunkHistory) < 4410 {
break
}
chunk = chunkHistory
buf = buf[l:]
} else {
if len(buf) >= 4410 {
chunk = buf[:4410]
buf = buf[4410:]
} else {
chunkHistory = chunkHistory[:0]
chunkHistory = append(chunkHistory, buf...)
break
}
}
TrySend(s.broker.ToModel, MsgToModel{
HasDetectorResult: true,
DetectorResult: DetectorResult{
Loudness: s.loudnessDetector.update(chunk),
Peaks: s.peakDetector.update(chunk),
},
})
}
s.broker.PutAudioBuffer(data)
case func():
data()
} }
} }
} }
// Close may theoretically block if the broker is full, but it should not happen in practice func (s *Detector) handleMsg(msg MsgToDetector) {
func (s *Detector) Close() { if msg.Reset {
s.broker.ToDetector <- MsgToDetector{Quit: true} s.loudnessDetector.reset()
s.peakDetector.reset()
}
if msg.HasWeightingType {
s.loudnessDetector.weighting = weightings[WeightingType(msg.WeightingType)]
s.loudnessDetector.reset()
}
if msg.HasOversampling {
s.peakDetector.oversampling = msg.Oversampling
s.peakDetector.reset()
}
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
}
}
TrySend(s.broker.ToModel, MsgToModel{
HasDetectorResult: true,
DetectorResult: DetectorResult{
Loudness: s.loudnessDetector.update(chunk),
Peaks: s.peakDetector.update(chunk),
},
})
}
}
} }
func makeLoudnessDetector(weighting WeightingType) loudnessDetector { func makeLoudnessDetector(weighting WeightingType) loudnessDetector {

View File

@ -184,7 +184,7 @@ func (t *Tracker) KeyEvent(e key.Event, gtx C) {
t.OpenSong().Do() t.OpenSong().Do()
case "Quit": case "Quit":
if canQuit { if canQuit {
t.Quit().Do() t.RequestQuit().Do()
} }
case "SaveSong": case "SaveSong":
t.SaveSong().Do() t.SaveSong().Do()

View File

@ -336,7 +336,7 @@ func NewMenuBar(model *tracker.Model) *MenuBar {
{IconBytes: icons.ImageAudiotrack, Text: "Export Wav...", ShortcutText: keyActionMap["ExportWav"], Doer: model.Export()}, {IconBytes: icons.ImageAudiotrack, Text: "Export Wav...", ShortcutText: keyActionMap["ExportWav"], Doer: model.Export()},
} }
if canQuit { if canQuit {
ret.fileMenuItems = append(ret.fileMenuItems, MenuItem{IconBytes: icons.ActionExitToApp, Text: "Quit", ShortcutText: keyActionMap["Quit"], Doer: model.Quit()}) ret.fileMenuItems = append(ret.fileMenuItems, MenuItem{IconBytes: icons.ActionExitToApp, Text: "Quit", ShortcutText: keyActionMap["Quit"], Doer: model.RequestQuit()})
} }
ret.editMenuItems = []MenuItem{ ret.editMenuItems = []MenuItem{
{IconBytes: icons.ContentUndo, Text: "Undo", ShortcutText: keyActionMap["Undo"], Doer: model.Undo()}, {IconBytes: icons.ContentUndo, Text: "Undo", ShortcutText: keyActionMap["Undo"], Doer: model.Undo()},

View File

@ -5,7 +5,6 @@ import (
"image" "image"
"io" "io"
"path/filepath" "path/filepath"
"sync"
"time" "time"
"gioui.org/app" "gioui.org/app"
@ -50,7 +49,6 @@ type (
filePathString tracker.String filePathString tracker.String
quitWG sync.WaitGroup
execChan chan func() execChan chan func()
preferences Preferences preferences Preferences
@ -102,75 +100,75 @@ func NewTracker(model *tracker.Model) *Tracker {
t.Theme.Palette.Fg = primaryColor t.Theme.Palette.Fg = primaryColor
t.Theme.Palette.ContrastFg = black t.Theme.Palette.ContrastFg = black
t.TrackEditor.scrollTable.Focus() t.TrackEditor.scrollTable.Focus()
t.quitWG.Add(1)
return t return t
} }
func (t *Tracker) Main() { func (t *Tracker) Main() {
titleFooter := ""
w := t.newWindow()
t.InstrumentEditor.Focus() t.InstrumentEditor.Focus()
recoveryTicker := time.NewTicker(time.Second * 30) recoveryTicker := time.NewTicker(time.Second * 30)
t.Explorer = explorer.NewExplorer(w)
// Make a channel to read window events from.
events := make(chan event.Event)
// Make a channel to signal the end of processing a window event.
acks := make(chan struct{})
go eventLoop(w, events, acks)
var ops op.Ops var ops op.Ops
for { titlePath := ""
select { for !t.Quitted() {
case e := <-t.Broker().ToModel: w := t.newWindow()
t.ProcessMsg(e) w.Option(app.Title(titleFromPath(titlePath)))
w.Invalidate() t.Explorer = explorer.NewExplorer(w)
case e := <-events: acks := make(chan struct{})
switch e := e.(type) { events := make(chan event.Event)
case app.DestroyEvent: go func() {
acks <- struct{}{} for {
if canQuit { ev := w.Event()
t.Quit().Do() events <- ev
<-acks
if _, ok := ev.(app.DestroyEvent); ok {
return
} }
if !t.Quitted() { }
// TODO: uh oh, there's no way of canceling the destroyevent in gioui? so we create a new window just to show the dialog }()
w = t.newWindow() F:
t.Explorer = explorer.NewExplorer(w) for {
go eventLoop(w, events, acks) select {
} case e := <-t.Broker().ToModel:
case app.FrameEvent: t.ProcessMsg(e)
if titleFooter != t.filePathString.Value() { w.Invalidate()
titleFooter = t.filePathString.Value() case <-t.Broker().CloseGUI:
if titleFooter != "" { t.ForceQuit().Do()
w.Option(app.Title(fmt.Sprintf("Sointu Tracker - %v", titleFooter))) w.Perform(system.ActionClose)
} else { case e := <-events:
w.Option(app.Title("Sointu Tracker")) switch e := e.(type) {
case app.DestroyEvent:
if canQuit {
t.RequestQuit().Do()
}
acks <- struct{}{}
break F // this window is done, we need to create a new one
case app.FrameEvent:
if titlePath != t.filePathString.Value() {
titlePath = t.filePathString.Value()
w.Option(app.Title(titleFromPath(titlePath)))
}
gtx := app.NewContext(&ops, e)
if t.Playing().Value() && t.Follow().Value() {
t.TrackEditor.scrollTable.RowTitleList.CenterOn(t.PlaySongRow())
}
t.Layout(gtx, w)
e.Frame(gtx.Ops)
if t.Quitted() {
w.Perform(system.ActionClose)
} }
} }
gtx := app.NewContext(&ops, e)
if t.Playing().Value() && t.Follow().Value() {
t.TrackEditor.scrollTable.RowTitleList.CenterOn(t.PlaySongRow())
}
t.Layout(gtx, w)
e.Frame(gtx.Ops)
acks <- struct{}{}
default:
acks <- struct{}{} acks <- struct{}{}
case <-recoveryTicker.C:
t.SaveRecovery()
} }
case <-recoveryTicker.C:
t.SaveRecovery()
}
if t.Quitted() {
break
} }
} }
recoveryTicker.Stop() recoveryTicker.Stop()
w.Perform(system.ActionClose)
t.SaveRecovery() t.SaveRecovery()
t.quitWG.Done() close(t.Broker().FinishedGUI)
} }
func (t *Tracker) newWindow() *app.Window { func (t *Tracker) newWindow() *app.Window {
w := new(app.Window) w := new(app.Window)
w.Option(app.Title("Sointu Tracker"))
w.Option(app.Size(t.preferences.WindowSize())) w.Option(app.Size(t.preferences.WindowSize()))
if t.preferences.Window.Maximized { if t.preferences.Window.Maximized {
w.Option(app.Maximized.Option()) w.Option(app.Maximized.Option())
@ -178,21 +176,11 @@ func (t *Tracker) newWindow() *app.Window {
return w return w
} }
func eventLoop(w *app.Window, events chan<- event.Event, acks <-chan struct{}) { func titleFromPath(path string) string {
// Iterate window events, sending each to the old event loop and waiting for if path == "" {
// a signal that processing is complete before iterating again. return "Sointu Tracker"
for {
ev := w.Event()
events <- ev
<-acks
if _, ok := ev.(app.DestroyEvent); ok {
return
}
} }
} return fmt.Sprintf("Sointu Tracker - %s", path)
func (t *Tracker) WaitQuitted() {
t.quitWG.Wait()
} }
func (t *Tracker) Layout(gtx layout.Context, w *app.Window) { func (t *Tracker) Layout(gtx layout.Context, w *app.Window) {

View File

@ -309,6 +309,6 @@ func FuzzModel(f *testing.F) {
} }
} }
closeChan <- struct{}{} closeChan <- struct{}{}
broker.ToDetector <- tracker.MsgToDetector{Quit: true} broker.CloseDetector <- struct{}{}
}) })
} }