From 5f148dd4a0cd77ed1b5e34d725801ebdb83a34a3 Mon Sep 17 00:00:00 2001 From: Xavier Godart Date: Thu, 11 Jun 2026 19:50:12 +0200 Subject: [PATCH 1/8] perf: locking and stability improvements --- .golangci.yml | 29 +++++- core/field/grid.go | 138 +++++++++++++++++++++++----- core/field/grid_concurrency_test.go | 74 +++++++++++++++ core/field/grid_serialization.go | 3 +- core/field/grid_test.go | 3 +- core/field/snapshot.go | 70 ++++++++++++++ core/music/key_value.go | 3 +- core/music/meta/bank.go | 1 - core/music/meta/tempo.go | 1 - core/music/note.go | 3 +- core/node/cycle.go | 1 - core/node/dice.go | 3 +- core/node/emitter.go | 3 +- core/node/euclid.go | 1 - core/node/toll.go | 1 - core/theory/theory.go | 1 - filesystem/bank.go | 5 +- main.go | 3 +- ui/control.go | 1 - ui/node.go | 51 +++++----- ui/param/bank_cmd.go | 3 +- ui/param/cc.go | 5 +- ui/param/channel.go | 3 +- ui/param/default_device.go | 1 - ui/param/destination.go | 5 +- ui/param/device.go | 1 - ui/param/key.go | 4 +- ui/param/length.go | 3 +- ui/param/offset.go | 4 +- ui/param/probability.go | 3 +- ui/param/repeat.go | 4 +- ui/param/root_cmd.go | 1 - ui/param/scale_cmd.go | 1 - ui/param/steps.go | 4 +- ui/param/tempo_cmd.go | 3 +- ui/param/threshold.go | 4 +- ui/param/triggers.go | 3 +- ui/param/velocity.go | 3 +- ui/ui.go | 36 +++++--- 39 files changed, 351 insertions(+), 135 deletions(-) create mode 100644 core/field/grid_concurrency_test.go create mode 100644 core/field/snapshot.go diff --git a/.golangci.yml b/.golangci.yml index a0f9805..f1b2739 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,13 +1,32 @@ +version: "2" run: go: "1.23" linters: enable: - thelper - - gofumpt - tparallel - unconvert - unparam - -linters-settings: - gofumpt: - module-path: signls + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gofumpt + settings: + gofumpt: + module-path: runal + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/core/field/grid.go b/core/field/grid.go index c43472a..d51813e 100644 --- a/core/field/grid.go +++ b/core/field/grid.go @@ -1,14 +1,13 @@ package field import ( - "sync" - "signls/core/common" "signls/core/music" "signls/core/music/meta" "signls/core/node" "signls/core/theory" "signls/midi" + "sync" ) const ( @@ -18,8 +17,15 @@ const ( ) // Grid represents the main structure for the grid-based sequencer. +// +// The grid is accessed concurrently: the clock goroutine drives Update on +// every pulse, while the ui goroutine reads node state to render and mutates +// it in response to user input. All access to the shared state below (nodes, +// dimensions, pulse, key/scale, playing state, device) must be guarded by mu. +// Public methods acquire the lock themselves; the ui render path takes a +// consistent copy with Snapshot and wraps any remaining live reads with Read. type Grid struct { - mu sync.Mutex + mu sync.RWMutex midi midi.Midi device midi.Device @@ -59,23 +65,43 @@ func NewGrid(width, height int, midi midi.Midi, device string) *Grid { } grid.clock = common.NewClock(defaultTempo, func() { - if !grid.Playing { - return - } - if grid.SendClock { - grid.midi.SendClock(d.ID) - } grid.Update() }) return grid } +// Read runs fn while holding the read lock. The ui uses it to wrap reads that +// span several grid/node accesses (such as rendering the control bar) so they +// observe a consistent state without exposing the lock itself. For rendering +// the grid, prefer Snapshot, which copies the display state and releases the +// lock immediately. +func (g *Grid) Read(fn func()) { + g.mu.RLock() + defer g.mu.RUnlock() + fn() +} + +// TriggerNode manually arms and triggers the emitter at the given coordinates, +// if any. Used by the ui to trigger a node from a key press. +func (g *Grid) TriggerNode(x, y int) { + g.mu.Lock() + defer g.mu.Unlock() + e, ok := g.nodes[y][x].(*node.Emitter) + if !ok { + return + } + e.Arm() + e.Trig(g.Key, g.Scale, common.NONE, g.pulse/uint64(common.PulsesPerStep)) +} + // TogglePlay toggles the playing state of the grid. func (g *Grid) TogglePlay() { + g.mu.Lock() + defer g.mu.Unlock() g.Playing = !g.Playing if !g.Playing { - g.Reset() + g.reset() g.midi.SilenceAll() } @@ -90,6 +116,14 @@ func (g *Grid) TogglePlay() { } } +// SetPlaying sets the playing state of the grid. Used by the ui when switching +// banks to preserve the previous playing state. +func (g *Grid) SetPlaying(playing bool) { + g.mu.Lock() + defer g.mu.Unlock() + g.Playing = playing +} + // SetTempo sets the tempo of the grid. func (g *Grid) SetTempo(tempo float64) { g.clock.SetTempo(tempo) @@ -102,14 +136,18 @@ func (g *Grid) Tempo() float64 { // SetKey changes the root key of the grid and transposes all notes accordingly. func (g *Grid) SetKey(key theory.Key) { + g.mu.Lock() + defer g.mu.Unlock() g.Key = key - g.Transpose() + g.transpose() } // SetScale changes the scale of the grid and transposes all notes accordingly. func (g *Grid) SetScale(scale theory.Scale) { + g.mu.Lock() + defer g.mu.Unlock() g.Scale = scale - g.Transpose() + g.transpose() } // MidiDevice returns the name of the currently active MIDI device. @@ -119,6 +157,8 @@ func (g *Grid) MidiDevice() midi.Device { // SetMidiDevice sets the midi device. func (g *Grid) SetMidiDevice(device midi.Device) { + g.mu.Lock() + defer g.mu.Unlock() g.device = device } @@ -142,6 +182,8 @@ func (g *Grid) QuarterNote() bool { // CopyOrCut copies or cuts a selection of nodes from the grid to the clipboard. func (g *Grid) CopyOrCut(startX, startY, endX, endY int, cut bool) { + g.mu.Lock() + defer g.mu.Unlock() nodes := make([][]common.Node, endY-startY+1) for i := range nodes { nodes[i] = make([]common.Node, endX-startX+1) @@ -167,6 +209,8 @@ func (g *Grid) CopyOrCut(startX, startY, endX, endY int, cut bool) { // Paste pastes nodes from the clipboard into the grid at the specified location. func (g *Grid) Paste(startX, startY, endX, endY int) { + g.mu.Lock() + defer g.mu.Unlock() if len(g.clipboard) == 0 { return } @@ -193,30 +237,40 @@ func (g *Grid) Node(x, y int) common.Node { // AddNodeFromSymbol adds a node to the grid based on a given symbol. func (g *Grid) AddNodeFromSymbol(symbol string, x, y int) { + g.mu.Lock() + defer g.mu.Unlock() switch symbol { case "b": - g.AddNode(node.NewBangEmitter(g.midi, &g.device, common.NONE, !g.Playing), x, y) + g.addNode(node.NewBangEmitter(g.midi, &g.device, common.NONE, !g.Playing), x, y) case "s": - g.AddNode(node.NewSpreadEmitter(g.midi, &g.device, common.NONE), x, y) + g.addNode(node.NewSpreadEmitter(g.midi, &g.device, common.NONE), x, y) case "c": - g.AddNode(node.NewCycleEmitter(g.midi, &g.device, common.NONE), x, y) + g.addNode(node.NewCycleEmitter(g.midi, &g.device, common.NONE), x, y) case "d": - g.AddNode(node.NewDiceEmitter(g.midi, &g.device, common.NONE), x, y) + g.addNode(node.NewDiceEmitter(g.midi, &g.device, common.NONE), x, y) case "t": - g.AddNode(node.NewTollEmitter(g.midi, &g.device, common.NONE), x, y) + g.addNode(node.NewTollEmitter(g.midi, &g.device, common.NONE), x, y) case "e": - g.AddNode(node.NewEuclidEmitter(g.midi, &g.device, common.NONE), x, y) + g.addNode(node.NewEuclidEmitter(g.midi, &g.device, common.NONE), x, y) case "z": - g.AddNode(node.NewZoneEmitter(g.midi, &g.device, common.NONE), x, y) + g.addNode(node.NewZoneEmitter(g.midi, &g.device, common.NONE), x, y) case "p": - g.AddNode(node.NewPassEmitter(g.midi, &g.device, common.NONE), x, y) + g.addNode(node.NewPassEmitter(g.midi, &g.device, common.NONE), x, y) case "h": - g.AddNode(node.NewHoleEmitter(common.NONE, x, y, g.Width, g.Height), x, y) + g.addNode(node.NewHoleEmitter(common.NONE, x, y, g.Width, g.Height), x, y) } } // AddNode adds a node to the grid at the specified coordinates. func (g *Grid) AddNode(e common.Node, x, y int) { + g.mu.Lock() + defer g.mu.Unlock() + g.addNode(e, x, y) +} + +// addNode adds a node to the grid at the specified coordinates. The caller must +// hold the lock. +func (g *Grid) addNode(e common.Node, x, y int) { destinationNode, isDestBehavior := g.nodes[y][x].(common.Behavioral) newNode, isNewBehavior := e.(common.Behavioral) if isDestBehavior && isNewBehavior { @@ -228,6 +282,8 @@ func (g *Grid) AddNode(e common.Node, x, y int) { // RemoveNodes removes nodes from a specified region of the grid. func (g *Grid) RemoveNodes(startX, startY, endX, endY int) { + g.mu.Lock() + defer g.mu.Unlock() for y := startY; y <= endY; y++ { for x := startX; x <= endX; x++ { g.nodes[y][x] = nil @@ -237,6 +293,8 @@ func (g *Grid) RemoveNodes(startX, startY, endX, endY int) { // ToggleNodeMutes toggles the mute state for all nodes in a specified region. func (g *Grid) ToggleNodeMutes(startX, startY, endX, endY int) { + g.mu.Lock() + defer g.mu.Unlock() for y := startY; y <= endY; y++ { for x := startX; x <= endX; x++ { if _, ok := g.nodes[y][x].(music.Audible); !ok { @@ -249,6 +307,8 @@ func (g *Grid) ToggleNodeMutes(startX, startY, endX, endY int) { // SetAllNodeMutes sets the mute state for all nodes in the grid. func (g *Grid) SetAllNodeMutes(mute bool) { + g.mu.Lock() + defer g.mu.Unlock() for y := 0; y < g.Height; y++ { for x := 0; x < g.Width; x++ { if _, ok := g.nodes[y][x].(music.Audible); !ok { @@ -260,11 +320,18 @@ func (g *Grid) SetAllNodeMutes(mute bool) { } // Update advances the grid by one step, moving signals and triggering emitters. +// It is called by the clock goroutine on every pulse. func (g *Grid) Update() { g.mu.Lock() defer g.mu.Unlock() + if !g.Playing { + return + } + if g.SendClock { + g.midi.SendClock(g.device.ID) + } if g.pulse%uint64(common.PulsesPerStep) != 0 { - g.Tick() + g.tick() return } for y := g.Height - 1; y >= 0; y-- { @@ -291,8 +358,9 @@ func (g *Grid) Update() { g.pulse++ } -// Tick updates all active notes within the grid on every pulse. -func (g *Grid) Tick() { +// tick updates all active notes within the grid on every pulse. The caller +// must hold the lock. +func (g *Grid) tick() { for y := 0; y < g.Height; y++ { for x := 0; x < g.Width; x++ { if n, ok := g.nodes[y][x].(common.Tickable); ok { @@ -305,6 +373,14 @@ func (g *Grid) Tick() { // Transpose transposes all notes in the grid to match the current key and scale. func (g *Grid) Transpose() { + g.mu.Lock() + defer g.mu.Unlock() + g.transpose() +} + +// transpose transposes all notes in the grid to match the current key and +// scale. The caller must hold the lock. +func (g *Grid) transpose() { for y := 0; y < g.Height; y++ { for x := 0; x < g.Width; x++ { if n, ok := g.nodes[y][x].(music.Audible); ok { @@ -318,6 +394,12 @@ func (g *Grid) Transpose() { func (g *Grid) Reset() { g.mu.Lock() defer g.mu.Unlock() + g.reset() +} + +// reset stops playback and resets the grid to its initial state. The caller +// must hold the lock. +func (g *Grid) reset() { g.Playing = false g.pulse = 0 for y := 0; y < g.Height; y++ { @@ -457,6 +539,14 @@ func (g *Grid) Teleport(t *node.HoleEmitter, m common.Node, x, y int) { // Resize changes the size of the grid and preserves existing nodes within the new dimensions. func (g *Grid) Resize(newWidth, newHeight int) { + g.mu.Lock() + defer g.mu.Unlock() + g.resize(newWidth, newHeight) +} + +// resize changes the size of the grid and preserves existing nodes within the +// new dimensions. The caller must hold the lock. +func (g *Grid) resize(newWidth, newHeight int) { newNodes := make([][]common.Node, newHeight) for i := range newNodes { newNodes[i] = make([]common.Node, newWidth) diff --git a/core/field/grid_concurrency_test.go b/core/field/grid_concurrency_test.go new file mode 100644 index 0000000..28f95cc --- /dev/null +++ b/core/field/grid_concurrency_test.go @@ -0,0 +1,74 @@ +package field + +import ( + "signls/core/common" + "signls/core/node" + "signls/midi" + "sync" + "testing" +) + +// TestGridConcurrentAccess exercises the grid the way the running app does: the +// clock goroutine drives Update while the ui goroutine renders (reads node +// state) and mutates the grid in response to input. It is meant to be run with +// the race detector (go test -race) — before the grid was guarded by its mutex +// on every access path, this reproduced data races and could panic with an +// index-out-of-range when Resize shrank the grid mid-Update. +func TestGridConcurrentAccess(t *testing.T) { + m := &midi.Mock{} + grid := NewGrid(32, 32, m, "") + device := m.NewDevice("", "") + grid.AddNode(node.NewBangEmitter(m, &device, common.DOWN|common.RIGHT, true), 7, 7) + grid.AddNode(node.NewSpreadEmitter(m, &device, common.DOWN), 11, 7) + grid.AddNode(node.NewSpreadEmitter(m, &device, common.LEFT), 11, 11) + grid.TogglePlay() + + const iterations = 2000 + var wg sync.WaitGroup + + // Clock goroutine: advance the grid on every pulse. + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < iterations; i++ { + grid.Update() + } + }() + + // Render goroutine: take a display snapshot and read live control state + // under the read lock, like View. + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < iterations; i++ { + cells := grid.Snapshot() + for y := range cells { + for x := range cells[y] { + _ = cells[y][x].Symbol + } + } + grid.Read(func() { + _ = grid.Pulse() + _ = grid.QuarterNote() + }) + } + }() + + // Input goroutine: mutate the grid (resize, add/remove, trigger) like the ui. + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < iterations; i++ { + if i%2 == 0 { + grid.Resize(16, 16) + } else { + grid.Resize(32, 32) + } + grid.AddNodeFromSymbol("s", 1, 1) + grid.TriggerNode(1, 1) + grid.RemoveNodes(1, 1, 1, 1) + } + }() + + wg.Wait() +} diff --git a/core/field/grid_serialization.go b/core/field/grid_serialization.go index 665dfd2..7939524 100644 --- a/core/field/grid_serialization.go +++ b/core/field/grid_serialization.go @@ -2,7 +2,6 @@ package field import ( "log" - "signls/core/common" "signls/core/music" "signls/core/node" @@ -103,7 +102,7 @@ func (g *Grid) Load(index int, grid filesystem.Grid) { g.Scale = theory.Scale(grid.Scale) g.SendClock = grid.SendClock g.SendTransport = grid.SendTransport - g.Resize(grid.Width, grid.Height) + g.resize(grid.Width, grid.Height) g.nodes = make([][]common.Node, g.Height) for i := range g.nodes { diff --git a/core/field/grid_test.go b/core/field/grid_test.go index a19242e..65d981c 100644 --- a/core/field/grid_test.go +++ b/core/field/grid_test.go @@ -2,11 +2,10 @@ package field import ( "fmt" - "testing" - "signls/core/common" "signls/core/node" "signls/midi" + "testing" ) var benchmarks = []struct { diff --git a/core/field/snapshot.go b/core/field/snapshot.go new file mode 100644 index 0000000..50f8e04 --- /dev/null +++ b/core/field/snapshot.go @@ -0,0 +1,70 @@ +package field + +import ( + "signls/core/common" + "signls/core/music" + "signls/core/node" +) + +// CellKind classifies a grid cell so the ui can pick the right rendering +// without inspecting (and racing on) live node state. +type CellKind uint8 + +const ( + // CellEmpty is an empty grid position. + CellEmpty CellKind = iota + // CellSignal is a moving signal (common.Movable). + CellSignal + // CellAudible is an emitter that triggers notes (music.Audible). + CellAudible + // CellHole is a teleport hole (*node.HoleEmitter). + CellHole +) + +// Cell is an immutable, display-oriented copy of a single grid position. It +// holds everything the ui needs to render a cell, so the render path never +// touches live node state. +type Cell struct { + Kind CellKind + Symbol string + Color string + Activated bool + Muted bool +} + +// Snapshot returns an immutable copy of the grid's display state, taken under +// the read lock. The ui renders from the returned cells without holding the +// lock, so the (slow) render never blocks the clock goroutine — only the fast +// copy does. The returned slice is owned by the caller. +func (g *Grid) Snapshot() [][]Cell { + g.mu.RLock() + defer g.mu.RUnlock() + + cells := make([][]Cell, g.Height) + for y := 0; y < g.Height; y++ { + cells[y] = make([]Cell, g.Width) + for x := 0; x < g.Width; x++ { + n := g.nodes[y][x] + if n == nil { + continue + } + c := Cell{ + Symbol: n.Symbol(), + Color: n.Color(), + Activated: n.Activated(), + } + // Mirror the type precedence used by the renderer. + switch t := n.(type) { + case common.Movable: + c.Kind = CellSignal + case music.Audible: + c.Kind = CellAudible + c.Muted = t.Muted() + case *node.HoleEmitter: + c.Kind = CellHole + } + cells[y][x] = c + } + } + return cells +} diff --git a/core/music/key_value.go b/core/music/key_value.go index b84b58f..4ed9c0f 100644 --- a/core/music/key_value.go +++ b/core/music/key_value.go @@ -3,10 +3,9 @@ package music import ( "math" "math/rand" - "time" - "signls/core/theory" "signls/midi" + "time" ) const ( diff --git a/core/music/meta/bank.go b/core/music/meta/bank.go index b4dbd2e..97fc3db 100644 --- a/core/music/meta/bank.go +++ b/core/music/meta/bank.go @@ -2,7 +2,6 @@ package meta import ( "fmt" - "signls/core/common" ) diff --git a/core/music/meta/tempo.go b/core/music/meta/tempo.go index 7d82382..e0d8842 100644 --- a/core/music/meta/tempo.go +++ b/core/music/meta/tempo.go @@ -2,7 +2,6 @@ package meta import ( "fmt" - "signls/core/common" ) diff --git a/core/music/note.go b/core/music/note.go index 34a7a6d..f1ef568 100644 --- a/core/music/note.go +++ b/core/music/note.go @@ -3,12 +3,11 @@ package music import ( "fmt" "math/rand" - "time" - "signls/core/common" "signls/core/music/meta" "signls/core/theory" "signls/midi" + "time" ) // Constants defining default values for note properties and their limits. diff --git a/core/node/cycle.go b/core/node/cycle.go index ac93ec8..1325fb4 100644 --- a/core/node/cycle.go +++ b/core/node/cycle.go @@ -2,7 +2,6 @@ package node import ( "math" - "signls/core/common" "signls/core/music" "signls/midi" diff --git a/core/node/dice.go b/core/node/dice.go index 9728296..765096b 100644 --- a/core/node/dice.go +++ b/core/node/dice.go @@ -3,11 +3,10 @@ package node import ( "math" "math/rand" - "time" - "signls/core/common" "signls/core/music" "signls/midi" + "time" ) type DiceEmitter struct { diff --git a/core/node/emitter.go b/core/node/emitter.go index 58e08ca..a56805c 100644 --- a/core/node/emitter.go +++ b/core/node/emitter.go @@ -2,11 +2,10 @@ package node import ( "fmt" - "unicode/utf8" - "signls/core/common" "signls/core/music" "signls/core/theory" + "unicode/utf8" ) type Emitter struct { diff --git a/core/node/euclid.go b/core/node/euclid.go index 1b377f6..0ab78c6 100644 --- a/core/node/euclid.go +++ b/core/node/euclid.go @@ -2,7 +2,6 @@ package node import ( "fmt" - "signls/core/common" "signls/core/music" "signls/core/theory" diff --git a/core/node/toll.go b/core/node/toll.go index 095eb05..3bd77b3 100644 --- a/core/node/toll.go +++ b/core/node/toll.go @@ -2,7 +2,6 @@ package node import ( "math" - "signls/core/common" "signls/core/music" "signls/midi" diff --git a/core/theory/theory.go b/core/theory/theory.go index 2c42b02..3224d73 100644 --- a/core/theory/theory.go +++ b/core/theory/theory.go @@ -2,7 +2,6 @@ package theory import ( "math" - "signls/midi" ) diff --git a/filesystem/bank.go b/filesystem/bank.go index 769a698..3ece9a0 100644 --- a/filesystem/bank.go +++ b/filesystem/bank.go @@ -9,13 +9,12 @@ import ( "log" "os" "path/filepath" - "strings" - "sync" - "signls/core/common" "signls/core/music" "signls/core/music/meta" "signls/core/theory" + "strings" + "sync" ) const ( diff --git a/main.go b/main.go index a9e2e36..5a45dcd 100644 --- a/main.go +++ b/main.go @@ -6,12 +6,11 @@ import ( "fmt" "log" "os" - "strings" - "signls/core/field" "signls/filesystem" "signls/midi" "signls/ui" + "strings" tea "github.com/charmbracelet/bubbletea" ) diff --git a/ui/control.go b/ui/control.go index c9797ac..0516c5f 100644 --- a/ui/control.go +++ b/ui/control.go @@ -2,7 +2,6 @@ package ui import ( "fmt" - "signls/core/common" "signls/filesystem" "signls/ui/param" diff --git a/ui/node.go b/ui/node.go index bec6348..40e39a7 100644 --- a/ui/node.go +++ b/ui/node.go @@ -2,9 +2,7 @@ package ui import ( "log" - - "signls/core/common" - "signls/core/music" + "signls/core/field" "signls/core/node" "signls/ui/param" "signls/ui/util" @@ -41,12 +39,9 @@ func (m mainModel) inSelectionRange(x, y int) bool { y <= m.selectionY } -func (m mainModel) renderNode(n common.Node, x, y int) string { +func (m mainModel) renderNode(c field.Cell, x, y int) string { // render cursor - isCursor := false - if x == m.cursorX && y == m.cursorY && m.mode != BANK { - isCursor = true - } + isCursor := x == m.cursorX && y == m.cursorY && m.mode != BANK isTeleportDestination := false if m.mode == EDIT && len(m.params) > 0 { @@ -59,15 +54,15 @@ func (m mainModel) renderNode(n common.Node, x, y int) string { // render grid teleportDestinationSymbol := node.HoleDestinationSymbol - if n == nil && isCursor { + if c.Kind == field.CellEmpty && isCursor { return cursorStyle.Render(" ") - } else if n == nil && isTeleportDestination && !m.blink && m.mode != BANK { + } else if c.Kind == field.CellEmpty && isTeleportDestination && !m.blink && m.mode != BANK { return cursorStyle.Render(teleportDestinationSymbol) - } else if n == nil && isTeleportDestination && (m.blink || m.mode == BANK) { + } else if c.Kind == field.CellEmpty && isTeleportDestination && (m.blink || m.mode == BANK) { return teleportDestinationStyle.Render(teleportDestinationSymbol) - } else if n == nil && m.inSelectionRange(x, y) && m.mode != BANK { + } else if c.Kind == field.CellEmpty && m.inSelectionRange(x, y) && m.mode != BANK { return selectionStyle.Render("..") - } else if n == nil { + } else if c.Kind == field.CellEmpty { if (x+y)%2 == 0 { return " " } @@ -75,14 +70,14 @@ func (m mainModel) renderNode(n common.Node, x, y int) string { } // render node - switch t := n.(type) { - case common.Movable: + switch c.Kind { + case field.CellSignal: if isCursor { return cursorStyle.Render(" ") } return activeEmitterStyle.Render(" ") - case music.Audible: - symbol := util.Normalize(n.Symbol()) + case field.CellAudible: + symbol := util.Normalize(c.Symbol) if isCursor && m.mode != EDIT { return cursorStyle.Render(symbol) @@ -90,37 +85,37 @@ func (m mainModel) renderNode(n common.Node, x, y int) string { return teleportDestinationStyle.Render(teleportDestinationSymbol) } else if isCursor && m.mode == EDIT && m.blink { return cursorStyle.Render(symbol) - } else if n.Activated() && t.Muted() { + } else if c.Activated && c.Muted { return activeEmitterStyle.Render(symbol) - } else if t.Muted() { + } else if c.Muted { return mutedEmitterStyle.Render(symbol) - } else if n.Activated() { + } else if c.Activated { return activeEmitterStyle. - Foreground(lipgloss.Color(n.Color())). + Foreground(lipgloss.Color(c.Color)). Render(symbol) } else { return emitterStyle. - Background(lipgloss.Color(n.Color())). + Background(lipgloss.Color(c.Color)). Render(symbol) } - case *node.HoleEmitter: - symbol := n.Symbol() + case field.CellHole: + symbol := c.Symbol if isCursor && m.mode != EDIT { return cursorStyle.Render(symbol) } else if isCursor && m.mode == EDIT && m.blink { return cursorStyle.Render(symbol) - } else if n.Activated() { + } else if c.Activated { return activeEmitterStyle. - Foreground(lipgloss.Color(n.Color())). + Foreground(lipgloss.Color(c.Color)). Render(symbol) } else { return emitterStyle. - Background(lipgloss.Color(n.Color())). + Background(lipgloss.Color(c.Color)). Render(symbol) } default: - log.Fatalf("cannot render node: %+v", t) + log.Fatalf("cannot render node kind: %d", c.Kind) return "" } } diff --git a/ui/param/bank_cmd.go b/ui/param/bank_cmd.go index 01c245e..047adaa 100644 --- a/ui/param/bank_cmd.go +++ b/ui/param/bank_cmd.go @@ -2,11 +2,10 @@ package param import ( "fmt" - "strconv" - "signls/core/common" "signls/core/music" "signls/ui/util" + "strconv" ) const ( diff --git a/ui/param/cc.go b/ui/param/cc.go index c2c7d2a..0ed913c 100644 --- a/ui/param/cc.go +++ b/ui/param/cc.go @@ -2,13 +2,12 @@ package param import ( "fmt" - "strconv" - "strings" - "signls/core/common" "signls/core/music" "signls/midi" "signls/ui/util" + "strconv" + "strings" ) type CC struct { diff --git a/ui/param/channel.go b/ui/param/channel.go index e2be5ef..e2e4c59 100644 --- a/ui/param/channel.go +++ b/ui/param/channel.go @@ -2,11 +2,10 @@ package param import ( "fmt" - "strconv" - "signls/core/common" "signls/core/music" "signls/ui/util" + "strconv" ) type Channel struct { diff --git a/ui/param/default_device.go b/ui/param/default_device.go index e6f6296..8e3446c 100644 --- a/ui/param/default_device.go +++ b/ui/param/default_device.go @@ -2,7 +2,6 @@ package param import ( "fmt" - "signls/core/field" ) diff --git a/ui/param/destination.go b/ui/param/destination.go index 9f789d6..01201ba 100644 --- a/ui/param/destination.go +++ b/ui/param/destination.go @@ -2,11 +2,10 @@ package param import ( "fmt" - "strconv" - "strings" - "signls/core/common" "signls/core/node" + "strconv" + "strings" ) type Destination struct { diff --git a/ui/param/device.go b/ui/param/device.go index 442678e..b0c8e6b 100644 --- a/ui/param/device.go +++ b/ui/param/device.go @@ -2,7 +2,6 @@ package param import ( "fmt" - "signls/core/common" "signls/core/music" ) diff --git a/ui/param/key.go b/ui/param/key.go index 11ec7c8..6d5d0b0 100644 --- a/ui/param/key.go +++ b/ui/param/key.go @@ -2,13 +2,11 @@ package param import ( "fmt" - "time" - "signls/core/common" "signls/core/music" "signls/core/theory" - "signls/ui/util" + "time" ) type KeyMode uint8 diff --git a/ui/param/length.go b/ui/param/length.go index fdc10c8..a3a83a2 100644 --- a/ui/param/length.go +++ b/ui/param/length.go @@ -2,11 +2,10 @@ package param import ( "fmt" - "strconv" - "signls/core/common" "signls/core/music" "signls/ui/util" + "strconv" ) const ( diff --git a/ui/param/offset.go b/ui/param/offset.go index dad17f3..3e1c07f 100644 --- a/ui/param/offset.go +++ b/ui/param/offset.go @@ -2,12 +2,10 @@ package param import ( "fmt" - "strconv" - "signls/core/common" "signls/core/node" - "signls/ui/util" + "strconv" ) type Offset struct { diff --git a/ui/param/probability.go b/ui/param/probability.go index 05ae2e5..8e521f0 100644 --- a/ui/param/probability.go +++ b/ui/param/probability.go @@ -2,10 +2,9 @@ package param import ( "fmt" - "strconv" - "signls/core/common" "signls/core/music" + "strconv" ) const ( diff --git a/ui/param/repeat.go b/ui/param/repeat.go index 8515471..a44b725 100644 --- a/ui/param/repeat.go +++ b/ui/param/repeat.go @@ -3,12 +3,10 @@ package param import ( "fmt" "math" - "strconv" - "signls/core/common" "signls/core/node" - "signls/ui/util" + "strconv" ) type Repeat struct { diff --git a/ui/param/root_cmd.go b/ui/param/root_cmd.go index da22634..863cfbf 100644 --- a/ui/param/root_cmd.go +++ b/ui/param/root_cmd.go @@ -2,7 +2,6 @@ package param import ( "fmt" - "signls/core/common" "signls/core/music" "signls/ui/util" diff --git a/ui/param/scale_cmd.go b/ui/param/scale_cmd.go index 9f59fe5..c5e1e61 100644 --- a/ui/param/scale_cmd.go +++ b/ui/param/scale_cmd.go @@ -2,7 +2,6 @@ package param import ( "fmt" - "signls/core/common" "signls/core/music" "signls/ui/util" diff --git a/ui/param/steps.go b/ui/param/steps.go index 4e21ddd..e6debba 100644 --- a/ui/param/steps.go +++ b/ui/param/steps.go @@ -2,12 +2,10 @@ package param import ( "fmt" - "strconv" - "signls/core/common" "signls/core/node" - "signls/ui/util" + "strconv" ) type Steps struct { diff --git a/ui/param/tempo_cmd.go b/ui/param/tempo_cmd.go index 2d7bef1..be9a675 100644 --- a/ui/param/tempo_cmd.go +++ b/ui/param/tempo_cmd.go @@ -2,11 +2,10 @@ package param import ( "fmt" - "strconv" - "signls/core/common" "signls/core/music" "signls/ui/util" + "strconv" ) const ( diff --git a/ui/param/threshold.go b/ui/param/threshold.go index fce713d..5f71d83 100644 --- a/ui/param/threshold.go +++ b/ui/param/threshold.go @@ -3,12 +3,10 @@ package param import ( "fmt" "math" - "strconv" - "signls/core/common" "signls/core/node" - "signls/ui/util" + "strconv" ) type Threshold struct { diff --git a/ui/param/triggers.go b/ui/param/triggers.go index c89990d..9608ad4 100644 --- a/ui/param/triggers.go +++ b/ui/param/triggers.go @@ -2,11 +2,10 @@ package param import ( "fmt" - "strconv" - "signls/core/common" "signls/core/node" "signls/ui/util" + "strconv" ) const ( diff --git a/ui/param/velocity.go b/ui/param/velocity.go index 41f09c1..8b01ce8 100644 --- a/ui/param/velocity.go +++ b/ui/param/velocity.go @@ -2,11 +2,10 @@ package param import ( "fmt" - "strconv" - "signls/core/common" "signls/core/music" "signls/ui/util" + "strconv" ) type Velocity struct { diff --git a/ui/ui.go b/ui/ui.go index a923ced..04ffb83 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -2,14 +2,11 @@ package ui import ( "fmt" - "time" - - "signls/core/common" "signls/core/field" - "signls/core/node" "signls/filesystem" "signls/ui/param" "signls/ui/util" + "time" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" @@ -62,6 +59,7 @@ type mainModel struct { input textinput.Model params [][]param.Param gridParams []param.Param + cells [][]field.Cell bankClipboard filesystem.Grid mode mode version string @@ -262,11 +260,7 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.grid.Playing { return m, nil } - if _, ok := m.selectedNode().(*node.Emitter); !ok { - return m, nil - } - m.selectedNode().(*node.Emitter).Arm() - m.selectedNode().(*node.Emitter).Trig(m.grid.Key, m.grid.Scale, common.NONE, m.grid.Pulse()) + m.grid.TriggerNode(m.cursorX, m.cursorY) return m, nil case key.Matches(msg, m.keymap.Bank): m.selectedGrid = m.bank.Active @@ -358,6 +352,11 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m mainModel) View() string { + // Take a consistent copy of the grid's display state, then render the grid + // without holding any lock. The slow lipgloss render never blocks the clock + // goroutine — only the fast Snapshot copy does. + m.cells = m.grid.Snapshot() + help := lipgloss.NewStyle(). MarginLeft(2). Render(m.help.View(m.keymap)) @@ -383,9 +382,17 @@ func (m mainModel) View() string { ) } + // The control bar still reads live grid/param state (tempo, pulse, selected + // node, params). It's a single row, so render it under a short read lock. + var control string + m.grid.Read(func() { + control = m.renderControl() + }) + return lipgloss.JoinVertical( lipgloss.Left, m.renderGrid(), + control, paramHelp, help, ) @@ -444,15 +451,14 @@ func (m mainModel) activeParamPage() []param.Param { } func (m mainModel) renderGrid() string { - var lines []string + lines := make([]string, 0, m.viewport.Height) for y := m.viewport.offsetY; y < m.viewport.offsetY+m.viewport.Height; y++ { - var nodes []string + nodes := make([]string, 0, m.viewport.Width) for x := m.viewport.offsetX; x < m.viewport.offsetX+m.viewport.Width; x++ { - nodes = append(nodes, m.renderNode(m.grid.Nodes()[y][x], x, y)) + nodes = append(nodes, m.renderNode(m.cells[y][x], x, y)) } lines = append(lines, lipgloss.JoinHorizontal(lipgloss.Left, nodes...)) } - lines = append(lines, m.renderControl()) return lipgloss.JoinVertical(lipgloss.Left, lines...) } @@ -515,7 +521,7 @@ func (m mainModel) loadGridFromBank() mainModel { m.bank.Active = m.selectedGrid isPlaying := m.grid.Playing m.grid.Load(m.bank.Active, m.bank.ActiveGrid()) - m.grid.Playing = isPlaying + m.grid.SetPlaying(isPlaying) m.cursorX = 1 m.cursorY = 1 m.selectionX = 1 @@ -532,7 +538,7 @@ func (m mainModel) handleBankMetaCommand() (mainModel, tea.Cmd) { } m.bank.Active = m.grid.BankIndex m.grid.Load(m.bank.Active, m.bank.ActiveGrid()) - m.grid.Playing = true + m.grid.SetPlaying(true) m.mode = MOVE m.param = 0 m.paramPage = 0 From f0ef38afb2eab87b728736a68889f82c6c80f624 Mon Sep 17 00:00:00 2001 From: Xavier Godart Date: Fri, 12 Jun 2026 11:26:27 +0200 Subject: [PATCH 2/8] fix: race condition --- core/field/grid.go | 72 ++++++++++++++++++++++++-- core/field/grid_concurrency_test.go | 23 +++++++++ ui/node.go | 12 ++++- ui/param/clock_send.go | 4 +- ui/param/key.go | 4 +- ui/param/param.go | 11 ++++ ui/param/root.go | 4 +- ui/param/scale.go | 13 +---- ui/param/transport_send.go | 4 +- ui/ui.go | 78 ++++++++++++++++++----------- 10 files changed, 174 insertions(+), 51 deletions(-) diff --git a/core/field/grid.go b/core/field/grid.go index d51813e..743ea28 100644 --- a/core/field/grid.go +++ b/core/field/grid.go @@ -14,6 +14,8 @@ const ( defaultTempo = 120. defaultRootKey theory.Key = 60 defaultScale theory.Scale = theory.CHROMATIC + + maxKey int = 127 ) // Grid represents the main structure for the grid-based sequencer. @@ -72,16 +74,27 @@ func NewGrid(width, height int, midi midi.Midi, device string) *Grid { } // Read runs fn while holding the read lock. The ui uses it to wrap reads that -// span several grid/node accesses (such as rendering the control bar) so they -// observe a consistent state without exposing the lock itself. For rendering -// the grid, prefer Snapshot, which copies the display state and releases the -// lock immediately. +// span several grid/node accesses (such as rendering the control bar or +// building the parameter list) so they observe a consistent state without +// exposing the lock itself. For rendering the grid, prefer Snapshot, which +// copies the display state and releases the lock immediately. func (g *Grid) Read(fn func()) { g.mu.RLock() defer g.mu.RUnlock() fn() } +// Write runs fn while holding the write lock. The ui uses it to wrap mutations +// that don't go through a dedicated Grid method — chiefly editing the +// parameters of the selected nodes, which writes node state the clock goroutine +// reads while triggering. fn must not call a Grid method that locks, or it will +// deadlock. +func (g *Grid) Write(fn func()) { + g.mu.Lock() + defer g.mu.Unlock() + fn() +} + // TriggerNode manually arms and triggers the emitter at the given coordinates, // if any. Used by the ui to trigger a node from a key press. func (g *Grid) TriggerNode(x, y int) { @@ -150,6 +163,43 @@ func (g *Grid) SetScale(scale theory.Scale) { g.transpose() } +// ShiftKey changes the root key by delta (clamped to the midi range) and +// transposes all notes accordingly. The read and write happen under a single +// lock so it can't race with the clock goroutine mutating the key. +func (g *Grid) ShiftKey(delta int) { + g.mu.Lock() + defer g.mu.Unlock() + v := int(g.Key) + delta + if v < 0 || v > maxKey { + return + } + g.Key = theory.Key(v) + g.transpose() +} + +// ShiftScale cycles the scale by delta (wrapping around) and transposes all +// notes accordingly. The read and write happen under a single lock so it can't +// race with the clock goroutine mutating the scale. +func (g *Grid) ShiftScale(delta int) { + g.mu.Lock() + defer g.mu.Unlock() + scales := theory.AllScales() + idx := delta + for i, s := range scales { + if s == g.Scale { + idx = i + delta + break + } + } + if idx < 0 { + idx = len(scales) - 1 + } else if idx >= len(scales) { + idx = 0 + } + g.Scale = scales[idx] + g.transpose() +} + // MidiDevice returns the name of the currently active MIDI device. func (g *Grid) MidiDevice() midi.Device { return g.device @@ -162,6 +212,20 @@ func (g *Grid) SetMidiDevice(device midi.Device) { g.device = device } +// SetSendClock sets whether the grid sends midi clock to its device. +func (g *Grid) SetSendClock(send bool) { + g.mu.Lock() + defer g.mu.Unlock() + g.SendClock = send +} + +// SetSendTransport sets whether the grid sends midi transport messages. +func (g *Grid) SetSendTransport(send bool) { + g.mu.Lock() + defer g.mu.Unlock() + g.SendTransport = send +} + // Midi returns the Midi interface. func (g *Grid) Midi() midi.Midi { return g.midi diff --git a/core/field/grid_concurrency_test.go b/core/field/grid_concurrency_test.go index 28f95cc..002257a 100644 --- a/core/field/grid_concurrency_test.go +++ b/core/field/grid_concurrency_test.go @@ -2,6 +2,7 @@ package field import ( "signls/core/common" + "signls/core/music" "signls/core/node" "signls/midi" "sync" @@ -67,6 +68,28 @@ func TestGridConcurrentAccess(t *testing.T) { grid.AddNodeFromSymbol("s", 1, 1) grid.TriggerNode(1, 1) grid.RemoveNodes(1, 1, 1, 1) + grid.ShiftKey(1) + grid.ShiftScale(1) + } + }() + + // Param goroutine: edit a node's parameters the way the ui does — mutating + // node state under Write, and reading it back under Read (as the parameter + // list is built). This races with the clock's Trig without the locks. + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < iterations; i++ { + grid.Write(func() { + if a, ok := grid.Node(7, 7).(music.Audible); ok { + a.Note().SetVelocity(uint8(i % 128)) + } + }) + grid.Read(func() { + if a, ok := grid.Node(7, 7).(music.Audible); ok { + _ = a.Note().Velocity.Value() + } + }) } }() diff --git a/ui/node.go b/ui/node.go index 40e39a7..27497b5 100644 --- a/ui/node.go +++ b/ui/node.go @@ -32,6 +32,13 @@ var ( Foreground(lipgloss.AdaptiveColor{Light: "15", Dark: "0"}) ) +// gridBlankCell caches the rendered empty grid cell. On a sparse grid this is +// by far the most common cell, and its output is constant. It is computed +// lazily on first render (rather than at init) so lipgloss has already detected +// the terminal background for its adaptive color. renderNode runs only on the +// bubbletea (View) goroutine, so the lazy write needs no synchronization. +var gridBlankCell string + func (m mainModel) inSelectionRange(x, y int) bool { return x >= m.cursorX && x <= m.selectionX && @@ -66,7 +73,10 @@ func (m mainModel) renderNode(c field.Cell, x, y int) string { if (x+y)%2 == 0 { return " " } - return gridStyle.Render(" ") + if gridBlankCell == "" { + gridBlankCell = gridStyle.Render(" ") + } + return gridBlankCell } // render node diff --git a/ui/param/clock_send.go b/ui/param/clock_send.go index 17ea78c..997f2a9 100644 --- a/ui/param/clock_send.go +++ b/ui/param/clock_send.go @@ -32,11 +32,11 @@ func (c ClockSend) AltValue() int { } func (c ClockSend) Up() { - c.grid.SendClock = true + c.grid.SetSendClock(true) } func (c ClockSend) Down() { - c.grid.SendClock = false + c.grid.SetSendClock(false) } func (c ClockSend) Left() {} diff --git a/ui/param/key.go b/ui/param/key.go index 6d5d0b0..9c8d402 100644 --- a/ui/param/key.go +++ b/ui/param/key.go @@ -120,8 +120,10 @@ func (k *Key) SetAlt(value int) { } func (k *Key) Preview() { + // Copy the note synchronously so it happens under the caller's lock; the + // goroutine then plays the copy without touching shared node state. + n := *k.nodes[0].(music.Audible).Note() go func() { - n := *k.nodes[0].(music.Audible).Note() n.Play() time.Sleep(300 * time.Millisecond) n.Silence() diff --git a/ui/param/param.go b/ui/param/param.go index bf31282..b34288c 100644 --- a/ui/param/param.go +++ b/ui/param/param.go @@ -37,6 +37,17 @@ func NewParamsForNodes(grid *field.Grid, nodes []common.Node) [][]Param { return [][]Param{} } + // Building the parameter list reads node and grid state (key/scale, silent + // flags, ...) that the clock goroutine mutates while triggering, so take the + // read lock for the duration. + var params [][]Param + grid.Read(func() { + params = newParamsForNodes(grid, nodes) + }) + return params +} + +func newParamsForNodes(grid *field.Grid, nodes []common.Node) [][]Param { if isHomogeneousNode[*node.HoleEmitter](nodes) { return [][]Param{ { diff --git a/ui/param/root.go b/ui/param/root.go index 6e26fb1..732ad92 100644 --- a/ui/param/root.go +++ b/ui/param/root.go @@ -34,11 +34,11 @@ func (r Root) AltValue() int { } func (r Root) Up() { - r.Set(r.Value() + 1) + r.grid.ShiftKey(1) } func (r Root) Down() { - r.Set(r.Value() - 1) + r.grid.ShiftKey(-1) } func (r Root) Left() {} diff --git a/ui/param/scale.go b/ui/param/scale.go index e29ba26..90ab988 100644 --- a/ui/param/scale.go +++ b/ui/param/scale.go @@ -31,11 +31,11 @@ func (s Scale) AltValue() int { } func (s Scale) Up() { - s.Set(s.scaleIndex() + 1) + s.grid.ShiftScale(1) } func (s Scale) Down() { - s.Set(s.scaleIndex() - 1) + s.grid.ShiftScale(-1) } func (s Scale) Left() {} @@ -61,13 +61,4 @@ func (s Scale) Set(value int) { func (s Scale) SetAlt(value int) {} -func (s Scale) scaleIndex() int { - for i := 0; i < len(s.scales); i++ { - if s.grid.Scale == s.scales[i] { - return i - } - } - return 0 -} - func (s Scale) SetEditValue(input string) {} diff --git a/ui/param/transport_send.go b/ui/param/transport_send.go index e517d7c..b2e7159 100644 --- a/ui/param/transport_send.go +++ b/ui/param/transport_send.go @@ -32,11 +32,11 @@ func (t TransportSend) AltValue() int { } func (t TransportSend) Up() { - t.grid.SendTransport = true + t.grid.SetSendTransport(true) } func (t TransportSend) Down() { - t.grid.SendTransport = false + t.grid.SetSendTransport(false) } func (t TransportSend) Left() {} diff --git a/ui/ui.go b/ui/ui.go index 04ffb83..dab4afc 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -141,7 +141,9 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, m.keymap.EditNode): m.input.Blur() - m.activeParam().SetEditValue(m.input.Value()) + m.grid.Write(func() { + m.activeParam().SetEditValue(m.input.Value()) + }) return m, nil case key.Matches(msg, m.keymap.Cancel, m.keymap.EditInput): m.input.Blur() @@ -202,7 +204,9 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keymap.EditUp, m.keymap.EditRight, m.keymap.EditDown, m.keymap.EditLeft): dir := m.keymap.Direction(msg) if m.mode == MOVE { - param.NewDirection(m.selectedEmitters()).SetFromKeyString(dir) + m.grid.Write(func() { + param.NewDirection(m.selectedEmitters()).SetFromKeyString(dir) + }) return m, save(m) } m.handleParamEdit(dir) @@ -403,26 +407,31 @@ func (m mainModel) handleParamEdit(dir string) { return } - switch dir { - case "up": - m.activeParam().Up() - case "down": - m.activeParam().Down() - case "left": - m.activeParam().Left() - return // no preview for alt param - case "right": - m.activeParam().Right() - return // no preview for alt param - } + edit := func() { + switch dir { + case "up": + m.activeParam().Up() + case "down": + m.activeParam().Down() + case "left": + m.activeParam().Left() + case "right": + m.activeParam().Right() + } - switch p := m.activeParam().(type) { - case *param.Key: - if m.grid.Playing { - return + // Preview only for up/down (not the alt left/right edits), and only + // while stopped. The note copy happens here under the same lock. + if dir == "up" || dir == "down" { + if p, ok := m.activeParam().(*param.Key); ok && !m.grid.Playing { + p.Preview() + } } - p.Preview() } + + // In EDIT mode the params mutate node state directly, so take the write + // lock. In CONFIG mode they mutate grid state through locking Grid methods, + // so run them directly to avoid a re-entrant lock. + m.editParam(edit) } func (m mainModel) handleParamAltEdit(dir string) { @@ -430,16 +439,29 @@ func (m mainModel) handleParamAltEdit(dir string) { return } - switch dir { - case "up": - m.activeParam().AltUp() - case "down": - m.activeParam().AltDown() - case "left": - m.activeParam().AltLeft() - case "right": - m.activeParam().AltRight() + m.editParam(func() { + switch dir { + case "up": + m.activeParam().AltUp() + case "down": + m.activeParam().AltDown() + case "left": + m.activeParam().AltLeft() + case "right": + m.activeParam().AltRight() + } + }) +} + +// editParam runs a parameter mutation, holding the grid write lock when the +// active params mutate node state directly (EDIT mode). CONFIG-mode params +// serialize themselves through locking Grid methods, so they run unwrapped. +func (m mainModel) editParam(fn func()) { + if m.mode == EDIT { + m.grid.Write(fn) + return } + fn() } func (m mainModel) activeParam() param.Param { From da047c4ec64c05bf87e1508af6fbe85bb10d7b5a Mon Sep 17 00:00:00 2001 From: Xavier Godart Date: Fri, 12 Jun 2026 12:23:06 +0200 Subject: [PATCH 3/8] fix: concurrency fixes --- core/field/grid_concurrency_test.go | 29 +++++++++++++ core/field/grid_serialization.go | 17 +++++++- filesystem/bank.go | 39 +++++++++++++++++ ui/control.go | 10 +++-- ui/saver.go | 49 +++++++++++++++++++++ ui/saver_test.go | 46 ++++++++++++++++++++ ui/ui.go | 67 ++++++++++++++++++----------- 7 files changed, 226 insertions(+), 31 deletions(-) create mode 100644 ui/saver.go create mode 100644 ui/saver_test.go diff --git a/core/field/grid_concurrency_test.go b/core/field/grid_concurrency_test.go index 002257a..b86cb8d 100644 --- a/core/field/grid_concurrency_test.go +++ b/core/field/grid_concurrency_test.go @@ -1,9 +1,11 @@ package field import ( + "path/filepath" "signls/core/common" "signls/core/music" "signls/core/node" + "signls/filesystem" "signls/midi" "sync" "testing" @@ -25,6 +27,7 @@ func TestGridConcurrentAccess(t *testing.T) { grid.TogglePlay() const iterations = 2000 + const numGrids = 32 // matches filesystem bank size var wg sync.WaitGroup // Clock goroutine: advance the grid on every pulse. @@ -73,6 +76,32 @@ func TestGridConcurrentAccess(t *testing.T) { } }() + // Save goroutine: persist the grid the way the save() command does — reading + // all node state to build the serializable snapshot, concurrent with the + // clock. Writes go to a throwaway bank file in a temp dir. + bank := filesystem.New(filepath.Join(t.TempDir(), "bank.json")) + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < iterations/10; i++ { + grid.Save(bank) + } + }() + + // Bank goroutine: touch the bank the way the ui does (select, copy, clear, + // render), concurrent with the save goroutine writing it. + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < iterations; i++ { + bank.SetActive(i % numGrids) + _ = bank.ActiveIndex() + _ = bank.GridAt(i % numGrids) + _ = bank.AllGrids() + bank.ClearGrid((i + 1) % numGrids) + } + }() + // Param goroutine: edit a node's parameters the way the ui does — mutating // node state under Write, and reading it back under Read (as the parameter // list is built). This races with the clock's Trig without the locks. diff --git a/core/field/grid_serialization.go b/core/field/grid_serialization.go index 7939524..6a2e216 100644 --- a/core/field/grid_serialization.go +++ b/core/field/grid_serialization.go @@ -17,6 +17,19 @@ func NewFromBank(bankIndex int, grid filesystem.Grid, midi midi.Midi) *Grid { } func (g *Grid) Save(bank *filesystem.Bank) { + // Save runs in a background command goroutine, so read the grid and node + // state under the read lock to build the serializable snapshot, then write + // to disk (below) without holding the lock. + var fsGrid filesystem.Grid + g.Read(func() { + fsGrid = g.snapshotForSave() + }) + bank.Save(fsGrid) +} + +// snapshotForSave builds the serializable representation of the grid. The +// caller must hold the lock. +func (g *Grid) snapshotForSave() filesystem.Grid { nodes := []filesystem.Node{} for y := range g.nodes { @@ -75,7 +88,7 @@ func (g *Grid) Save(bank *filesystem.Bank) { } } - bank.Save(filesystem.Grid{ + return filesystem.Grid{ Nodes: nodes, Tempo: g.Tempo(), Height: g.Height, @@ -85,7 +98,7 @@ func (g *Grid) Save(bank *filesystem.Bank) { Scale: uint16(g.Scale), SendClock: g.SendClock, SendTransport: g.SendTransport, - }) + } } func (g *Grid) Load(index int, grid filesystem.Grid) { diff --git a/filesystem/bank.go b/filesystem/bank.go index 3ece9a0..d7ddb03 100644 --- a/filesystem/bank.go +++ b/filesystem/bank.go @@ -184,8 +184,47 @@ func (b *Bank) ActiveGrid() Grid { return b.Grids[b.Active] } +// ActiveIndex returns the index of the active grid. +func (b *Bank) ActiveIndex() int { + b.mu.Lock() + defer b.mu.Unlock() + return b.Active +} + +// SetActive sets the index of the active grid. +func (b *Bank) SetActive(nb int) { + b.mu.Lock() + defer b.mu.Unlock() + b.Active = nb +} + +// GridAt returns the grid at the given index. +func (b *Bank) GridAt(nb int) Grid { + b.mu.Lock() + defer b.mu.Unlock() + return b.Grids[nb] +} + +// SetGrid stores a grid at the given index. +func (b *Bank) SetGrid(nb int, grid Grid) { + b.mu.Lock() + defer b.mu.Unlock() + b.Grids[nb] = grid +} + +// AllGrids returns a copy of the bank's grids, safe to read without the lock. +func (b *Bank) AllGrids() []Grid { + b.mu.Lock() + defer b.mu.Unlock() + grids := make([]Grid, len(b.Grids)) + copy(grids, b.Grids) + return grids +} + // ClearGrid clears a given grid. func (b *Bank) ClearGrid(nb int) { + b.mu.Lock() + defer b.mu.Unlock() b.Grids[nb] = NewGrid() } diff --git a/ui/control.go b/ui/control.go index 0516c5f..6bcff20 100644 --- a/ui/control.go +++ b/ui/control.go @@ -83,12 +83,14 @@ func (m mainModel) renderControl() string { } func (m mainModel) bankSelection() string { + grids := m.bank.AllGrids() + active := m.bank.ActiveIndex() banks := make([]string, maxGrids) - for i, g := range m.bank.Grids[:maxGrids] { + for i, g := range grids[:maxGrids] { label := bankGridLabel(i, g) if i == m.selectedGrid { banks[i] = cursorStyle.MarginRight(1).Render(label) - } else if i == m.bank.Active { + } else if i == active { banks[i] = activeBankStyle.Render(label) } else if (i < gridsPerLine && i%2 == 0) || (i >= gridsPerLine && i%2 == 1) { banks[i] = bankStyle.Render(label) @@ -113,7 +115,7 @@ func (m mainModel) bankSelection() string { lipgloss.Left, lipgloss.JoinVertical( lipgloss.Left, - activeBankStyle.MarginRight(9).Render(bankGridLabel(m.bank.Active, m.bank.ActiveGrid())), + activeBankStyle.MarginRight(9).Render(bankGridLabel(active, m.bank.ActiveGrid())), cellStyle.Render(m.modeName()), ), pane, @@ -144,7 +146,7 @@ func (m mainModel) gridInfo() string { lipgloss.Left, fmt.Sprintf( "%s%s", - activeBankStyle.Render(bankGridLabel(m.bank.Active, m.bank.ActiveGrid())), + activeBankStyle.Render(bankGridLabel(m.bank.ActiveIndex(), m.bank.ActiveGrid())), m.bank.Filename(), ), ), diff --git a/ui/saver.go b/ui/saver.go new file mode 100644 index 0000000..1bfaa81 --- /dev/null +++ b/ui/saver.go @@ -0,0 +1,49 @@ +package ui + +import ( + "sync" + "time" +) + +// saveDebounce is how long the saver waits after the last change before writing +// to disk. It coalesces bursts of edits — such as a held key repeating, which +// would otherwise rewrite the entire bank file on every repeat — into a single +// write once the edits go quiet. +const saveDebounce = 400 * time.Millisecond + +// saver coalesces frequent save requests into a single debounced disk write. +// Each request (re)starts the debounce window; the save runs once requests stop +// for saveDebounce. The actual save is delegated to a caller-provided function, +// which must be safe to call from a background goroutine (the timer fires on +// its own goroutine). +type saver struct { + mu sync.Mutex + timer *time.Timer + interval time.Duration + save func() +} + +func newSaver(interval time.Duration, save func()) *saver { + return &saver{interval: interval, save: save} +} + +// request schedules a save, restarting the debounce window. It never blocks. +func (s *saver) request() { + s.mu.Lock() + defer s.mu.Unlock() + if s.timer == nil { + s.timer = time.AfterFunc(s.interval, s.save) + return + } + s.timer.Reset(s.interval) +} + +// stop cancels any pending save. Used on quit, where the final save is done +// synchronously instead. +func (s *saver) stop() { + s.mu.Lock() + defer s.mu.Unlock() + if s.timer != nil { + s.timer.Stop() + } +} diff --git a/ui/saver_test.go b/ui/saver_test.go new file mode 100644 index 0000000..64b3bd8 --- /dev/null +++ b/ui/saver_test.go @@ -0,0 +1,46 @@ +package ui + +import ( + "sync/atomic" + "testing" + "time" +) + +// TestSaverCoalesces verifies that a burst of requests within the debounce +// window results in a single save, and that a later request triggers another. +func TestSaverCoalesces(t *testing.T) { + var saves atomic.Int64 + const interval = 20 * time.Millisecond + s := newSaver(interval, func() { saves.Add(1) }) + + // A burst of requests spaced well under the interval must coalesce. + for i := 0; i < 10; i++ { + s.request() + time.Sleep(interval / 10) + } + time.Sleep(3 * interval) + if got := saves.Load(); got != 1 { + t.Fatalf("burst of requests: got %d saves, want 1", got) + } + + // A new request after the window triggers a second save. + s.request() + time.Sleep(3 * interval) + if got := saves.Load(); got != 2 { + t.Fatalf("after second request: got %d saves, want 2", got) + } +} + +// TestSaverStop verifies that a pending save is cancelled by stop. +func TestSaverStop(t *testing.T) { + var saves atomic.Int64 + const interval = 20 * time.Millisecond + s := newSaver(interval, func() { saves.Add(1) }) + + s.request() + s.stop() + time.Sleep(3 * interval) + if got := saves.Load(); got != 0 { + t.Fatalf("stopped saver: got %d saves, want 0", got) + } +} diff --git a/ui/ui.go b/ui/ui.go index dab4afc..474d547 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -60,6 +60,7 @@ type mainModel struct { params [][]param.Param gridParams []param.Param cells [][]field.Cell + saver *saver bankClipboard filesystem.Grid mode mode version string @@ -88,6 +89,7 @@ func New(config filesystem.Configuration, grid *field.Grid, bank *filesystem.Ban help: help.New(), input: ti, gridParams: param.NewParamsForGrid(grid), + saver: newSaver(saveDebounce, func() { grid.Save(bank) }), cursorX: 1, cursorY: 1, selectionX: 1, @@ -117,6 +119,14 @@ func save(m mainModel) tea.Cmd { } } +// requestSave schedules a debounced save instead of writing on every edit, so a +// burst of edits coalesces into a single disk write. It returns no command; the +// write happens on the saver's timer goroutine. +func (m mainModel) requestSave() tea.Cmd { + m.saver.request() + return nil +} + func (m mainModel) Init() tea.Cmd { return tea.Batch(tea.EnterAltScreen, tick(), blink()) } @@ -193,7 +203,7 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { dir := m.keymap.Direction(msg) if m.mode == EDIT || m.mode == CONFIG { m.handleParamAltEdit(dir) - return m, save(m) + return m, m.requestSave() } m.selectionX, m.selectionY = moveCursor( dir, 1, m.selectionX, m.selectionY, @@ -207,10 +217,10 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.grid.Write(func() { param.NewDirection(m.selectedEmitters()).SetFromKeyString(dir) }) - return m, save(m) + return m, m.requestSave() } m.handleParamEdit(dir) - return m, save(m) + return m, m.requestSave() case key.Matches(msg, m.keymap.AddBang, m.keymap.AddSpread, m.keymap.AddCycle, m.keymap.AddDice, m.keymap.AddToll, m.keymap.AddEuclid, m.keymap.AddZone, m.keymap.AddPass, m.keymap.AddHole): m.grid.AddNodeFromSymbol(m.keymap.EmitterSymbol(msg), m.cursorX, m.cursorY) newParams := param.NewParamsForNodes(m.grid, m.selectedEmitters()) @@ -221,14 +231,14 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.param = 0 } m.params = newParams - return m, save(m) + return m, m.requestSave() case key.Matches(msg, m.keymap.MuteNode): m.grid.ToggleNodeMutes(m.cursorX, m.cursorY, m.selectionX, m.selectionY) - return m, save(m) + return m, m.requestSave() case key.Matches(msg, m.keymap.MuteAllNode): m.grid.SetAllNodeMutes(!m.mute) m.mute = !m.mute - return m, save(m) + return m, m.requestSave() case key.Matches(msg, m.keymap.RemoveNode): if m.mode == BANK { m.bank.ClearGrid(m.selectedGrid) @@ -236,7 +246,7 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.mode = MOVE m.grid.RemoveNodes(m.cursorX, m.cursorY, m.selectionX, m.selectionY) - return m, save(m) + return m, m.requestSave() case key.Matches(msg, m.keymap.EditNode): if m.mode == BANK { m.mode = MOVE @@ -267,7 +277,7 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.grid.TriggerNode(m.cursorX, m.cursorY) return m, nil case key.Matches(msg, m.keymap.Bank): - m.selectedGrid = m.bank.Active + m.selectedGrid = m.bank.ActiveIndex() m.mode = m.toggleMode(BANK) return m, nil case key.Matches(msg, m.keymap.RootNoteUp): @@ -275,31 +285,31 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } param.Get("root", m.gridParams).Up() - return m, save(m) + return m, m.requestSave() case key.Matches(msg, m.keymap.RootNoteDown): if m.mode == EDIT { return m, nil } param.Get("root", m.gridParams).Down() - return m, save(m) + return m, m.requestSave() case key.Matches(msg, m.keymap.ScaleUp): if m.mode == EDIT { return m, nil } param.Get("scale", m.gridParams).Up() - return m, save(m) + return m, m.requestSave() case key.Matches(msg, m.keymap.ScaleDown): if m.mode == EDIT { return m, nil } param.Get("scale", m.gridParams).Down() - return m, save(m) + return m, m.requestSave() case key.Matches(msg, m.keymap.TempoUp): m.grid.SetTempo(m.grid.Tempo() + 1) - return m, save(m) + return m, m.requestSave() case key.Matches(msg, m.keymap.TempoDown): m.grid.SetTempo(m.grid.Tempo() - 1) - return m, save(m) + return m, m.requestSave() case key.Matches(msg, m.keymap.Configuration): m.mode = m.toggleMode(CONFIG) m.params = param.NewParamsForMidi(m.grid) @@ -308,16 +318,16 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case key.Matches(msg, m.keymap.Copy): if m.mode == BANK { - m.bankClipboard = m.bank.Grids[m.selectedGrid] + m.bankClipboard = m.bank.GridAt(m.selectedGrid) return m, nil } m.grid.CopyOrCut(m.cursorX, m.cursorY, m.selectionX, m.selectionY, false) return m, nil case key.Matches(msg, m.keymap.Cut): if m.mode == BANK { - m.bankClipboard = m.bank.Grids[m.selectedGrid] + m.bankClipboard = m.bank.GridAt(m.selectedGrid) m.bank.ClearGrid(m.selectedGrid) - if m.bank.Active == m.selectedGrid { + if m.bank.ActiveIndex() == m.selectedGrid { return m.loadGridFromBank(), tea.WindowSize() } return m, tea.WindowSize() @@ -326,11 +336,11 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case key.Matches(msg, m.keymap.Paste): if m.mode == BANK { - m.bank.Grids[m.selectedGrid] = m.bankClipboard + m.bank.SetGrid(m.selectedGrid, m.bankClipboard) } m.grid.Paste(m.cursorX, m.cursorY, m.selectionX, m.selectionY) m.params = param.NewParamsForNodes(m.grid, m.selectedEmitters()) - return m, save(m) + return m, m.requestSave() case key.Matches(msg, m.keymap.Cancel): m.mode = MOVE m.selectionX = m.cursorX @@ -342,12 +352,15 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.selectionX, m.selectionY = m.cursorX, m.cursorY m.grid.Resize(m.viewport.Width, m.viewport.Height) m.viewport.Update(m.cursorX, m.cursorY, m.grid.Width, m.grid.Height) - return m, save(m) + return m, m.requestSave() case key.Matches(msg, m.keymap.Help): m.help.ShowAll = !m.help.ShowAll return m, tea.ClearScreen case key.Matches(msg, m.keymap.Quit): m.grid.Reset() + // Cancel any pending debounced save; the final save below is + // synchronous so the latest state is persisted before quitting. + m.saver.stop() return m, tea.Sequence(save(m), tea.Quit) } } @@ -540,9 +553,9 @@ func (m *mainModel) moveBankGrid(dir string) { } func (m mainModel) loadGridFromBank() mainModel { - m.bank.Active = m.selectedGrid + m.bank.SetActive(m.selectedGrid) isPlaying := m.grid.Playing - m.grid.Load(m.bank.Active, m.bank.ActiveGrid()) + m.grid.Load(m.selectedGrid, m.bank.ActiveGrid()) m.grid.SetPlaying(isPlaying) m.cursorX = 1 m.cursorY = 1 @@ -555,11 +568,15 @@ func (m mainModel) loadGridFromBank() mainModel { } func (m mainModel) handleBankMetaCommand() (mainModel, tea.Cmd) { - if m.grid.BankIndex == m.bank.Active { + // BankIndex is written by the clock goroutine (meta commands), so read it + // under the lock. + var bankIndex int + m.grid.Read(func() { bankIndex = m.grid.BankIndex }) + if bankIndex == m.bank.ActiveIndex() { return m, tick() } - m.bank.Active = m.grid.BankIndex - m.grid.Load(m.bank.Active, m.bank.ActiveGrid()) + m.bank.SetActive(bankIndex) + m.grid.Load(bankIndex, m.bank.ActiveGrid()) m.grid.SetPlaying(true) m.mode = MOVE m.param = 0 From 17e2df75cd28a2d38b512576331caace2816a21a Mon Sep 17 00:00:00 2001 From: Xavier Godart Date: Fri, 12 Jun 2026 15:39:58 +0200 Subject: [PATCH 4/8] feat: rework platform config directories --- README.md | 17 +++++++++-- filesystem/path.go | 58 +++++++++++++++++++++++++++++++++++ filesystem/path_test.go | 67 +++++++++++++++++++++++++++++++++++++++++ main.go | 19 +++++++++--- 4 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 filesystem/path.go create mode 100644 filesystem/path_test.go diff --git a/README.md b/README.md index 3c917a6..a28676b 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,17 @@ make GOLANG_OS=linux GOLANG_ARCH=arm64 build Hit `?` to see all keybindings. `esc` to quit. +### Files location + +Signls stores its `config.json` and bank files in your user config directory: + +| OS | Location | +| --- | --- | +| Linux / macOS | `~/.config/emprcl/signls/` (or `$XDG_CONFIG_HOME/emprcl/signls/`) | +| Windows | `%AppData%\emprcl\signls\` | + +You can override either with an explicit path using the `--config` and `--bank` flags. + Some companion apps that receive MIDI for testing Signls: - [Webmidi synths](https://synth.playtronica.com/) - [Enfer](https://neauoire.github.io/Enfer/) ([github](https://github.com/neauoire/Enfer)) _*works only on linux*_ @@ -68,7 +79,7 @@ Some companion apps that receive MIDI for testing Signls: ### Keyboard mapping -Keys mapping is fully customizable. After running signls for the first time, a `config.json` is created. +Keys mapping is fully customizable. After running signls for the first time, a `config.json` is created in your [config directory](#files-location). You can edit all the keys inside it. You can select one of the default keyboard layouts available: @@ -114,8 +125,8 @@ For qwerty keyboards, here's the default mapping: ### Bank management -Each time you start Signls, a json file (default: `default.json`) containing 32 grid slots is loaded. -For selecting a different file, use the `--bank` flag: +Each time you start Signls, a json file (default: `default.json`, in your [config directory](#files-location)) containing 32 grid slots is loaded. +For selecting a different file, use the `--bank` flag (relative or absolute path): ```sh ./signls --bank my-grids.json ``` diff --git a/filesystem/path.go b/filesystem/path.go new file mode 100644 index 0000000..e2969f2 --- /dev/null +++ b/filesystem/path.go @@ -0,0 +1,58 @@ +package filesystem + +import ( + "os" + "path/filepath" + "runtime" +) + +// appOrgName and appDirName form the per-user subdirectory (emprcl/signls) +// under the config directory where signls stores its config and bank files. +const ( + appOrgName = "emprcl" + appDirName = "signls" +) + +// configBase returns the base config directory for the current OS. Unlike +// os.UserConfigDir (which is ~/Library/Application Support on macOS), terminal +// apps conventionally use ~/.config on both Linux and macOS, so we use that — +// honoring XDG_CONFIG_HOME — and fall back to %AppData% on Windows. +func configBase() (string, error) { + if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" { + return dir, nil + } + if runtime.GOOS == "windows" { + return os.UserConfigDir() + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".config"), nil +} + +// AppDir returns the per-user signls config directory, creating it if needed. +// It resolves to $XDG_CONFIG_HOME/emprcl/signls or ~/.config/emprcl/signls on +// Linux and macOS, and %AppData%\emprcl\signls on Windows. +func AppDir() (string, error) { + base, err := configBase() + if err != nil { + return "", err + } + appDir := filepath.Join(base, appOrgName, appDirName) + if err := os.MkdirAll(appDir, 0o755); err != nil { + return "", err + } + return appDir, nil +} + +// DefaultPath resolves name inside AppDir. If the config directory can't be +// determined, it falls back to the bare name (the current working directory), +// preserving the previous behavior. +func DefaultPath(name string) string { + dir, err := AppDir() + if err != nil { + return name + } + return filepath.Join(dir, name) +} diff --git a/filesystem/path_test.go b/filesystem/path_test.go new file mode 100644 index 0000000..63021b0 --- /dev/null +++ b/filesystem/path_test.go @@ -0,0 +1,67 @@ +package filesystem + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +// redirectConfigDir points the config base at a temp dir via XDG_CONFIG_HOME, +// which configBase honors on every platform. +func redirectConfigDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + return dir +} + +func TestAppDirCreatesUnderConfigDir(t *testing.T) { + redirectConfigDir(t) + + appDir, err := AppDir() + if err != nil { + t.Fatalf("AppDir: %v", err) + } + if filepath.Base(appDir) != appDirName { + t.Errorf("AppDir = %q, want base %q", appDir, appDirName) + } + if parent := filepath.Base(filepath.Dir(appDir)); parent != appOrgName { + t.Errorf("AppDir = %q, want parent %q", appDir, appOrgName) + } + if info, err := os.Stat(appDir); err != nil || !info.IsDir() { + t.Errorf("AppDir %q was not created as a directory (err=%v)", appDir, err) + } +} + +func TestDefaultPathJoinsName(t *testing.T) { + redirectConfigDir(t) + + appDir, err := AppDir() + if err != nil { + t.Fatalf("AppDir: %v", err) + } + if got, want := DefaultPath("config.json"), filepath.Join(appDir, "config.json"); got != want { + t.Errorf("DefaultPath = %q, want %q", got, want) + } +} + +// TestAppDirFallsBackToDotConfig checks that, with XDG_CONFIG_HOME unset, Unix +// platforms (Linux and macOS) use ~/.config rather than os.UserConfigDir's +// macOS default of ~/Library/Application Support. +func TestAppDirFallsBackToDotConfig(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("windows uses %AppData%, not ~/.config") + } + home := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("HOME", home) + + appDir, err := AppDir() + if err != nil { + t.Fatalf("AppDir: %v", err) + } + if want := filepath.Join(home, ".config", appOrgName, appDirName); appDir != want { + t.Errorf("AppDir = %q, want %q", appDir, want) + } +} diff --git a/main.go b/main.go index 5a45dcd..c120bf3 100644 --- a/main.go +++ b/main.go @@ -19,8 +19,8 @@ import ( var AppVersion string func main() { - configFile := flag.String("config", "config.json", "config file to load or create") - bankFile := flag.String("bank", "default.json", "bank file to store grids") + configFile := flag.String("config", "", "config file to load or create (default: /signls/config.json)") + bankFile := flag.String("bank", "", "bank file to store grids (default: /signls/default.json)") keyboard := flag.String("keyboard", "", "keyboard layout (qwerty, qwerty-mac, azerty, azerty-mac)") version := flag.Bool("version", false, "print current version") debug := flag.Bool("debug", false, "enable debug mode") @@ -31,7 +31,18 @@ func main() { os.Exit(0) } - config := filesystem.NewConfiguration(*configFile, strings.TrimSuffix(AppVersion, "\n"), *keyboard) + // Default config and bank files live in the per-user config directory; an + // explicit -config/-bank path is honored as given. + configPath := *configFile + if configPath == "" { + configPath = filesystem.DefaultPath("config.json") + } + bankPath := *bankFile + if bankPath == "" { + bankPath = filesystem.DefaultPath("default.json") + } + + config := filesystem.NewConfiguration(configPath, strings.TrimSuffix(AppVersion, "\n"), *keyboard) midi, err := midi.New() if err != nil { @@ -47,7 +58,7 @@ func main() { defer f.Close() } - bank := filesystem.New(*bankFile) + bank := filesystem.New(bankPath) grid := field.NewFromBank(bank.Active, bank.ActiveGrid(), midi) p := tea.NewProgram(ui.New(config, grid, bank)) From 3492811c42a4e25a60f57640e34e14c0d625811e Mon Sep 17 00:00:00 2001 From: Xavier Godart Date: Fri, 12 Jun 2026 16:08:54 +0200 Subject: [PATCH 5/8] ci: use goreleaser --- .github/workflows/build.yml | 130 +++++++++++++++--------------------- .gitignore | 1 + .goreleaser.yaml | 94 ++++++++++++++++++++++++++ Makefile | 15 +++++ README.md | 12 ++++ main.go | 15 ++--- 6 files changed, 182 insertions(+), 85 deletions(-) create mode 100644 .goreleaser.yaml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8004a57..3dc0afb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,108 +11,84 @@ permissions: contents: write jobs: + # signls needs cgo for gomidi's rtmidi driver, so each platform is built on a + # matching native runner. GoReleaser only compiles the targets selected by the + # GR_TARGET env var (see .goreleaser.yaml). build: strategy: + fail-fast: false matrix: - os: [ubuntu-22.04, macos-13, windows-2022] + include: + - target: linux-amd64 + os: ubuntu-latest + - target: linux-arm64 + os: ubuntu-24.04-arm + - target: darwin + os: macos-latest runs-on: ${{ matrix.os }} - env: - CGO_ENABLED: 1 steps: - uses: actions/checkout@v4 - - - name: Install Alsa headers - run: sudo apt-get install libasound2-dev - if: startsWith(matrix.os, 'ubuntu') + with: + fetch-depth: 0 # full history & tags for version/changelog - name: Install Go uses: actions/setup-go@v5 with: - go-version: 1.23 - - - name: Release version number - run: echo '${{ github.ref_name}}' > VERSION - if: ${{ github.ref_type == 'tag' }} - - - name: Development version number - run: echo 'dev-${{ github.sha}}' > VERSION - if: ${{ github.ref_type == 'branch' }} + go-version: "1.23" - - name: Build linux|mac - run: go build -o bin/signls && chmod +x bin/signls - if: ${{ !startsWith(matrix.os, 'windows') }} - - - name: Build windows - run: go build -ldflags "-linkmode 'external' -extldflags '-static'" -o bin/signls.exe - if: startsWith(matrix.os, 'windows') - - - name: Tar.gz linux|mac files - run: tar -zcvf signls_${{ github.ref_name }}_${{ runner.os}}.tar.gz LICENSE -C bin signls - if: ${{ !startsWith(matrix.os, 'windows') }} - - - name: Zip windows files - shell: pwsh + - name: Install ALSA headers + if: startsWith(matrix.target, 'linux') run: | - Compress-Archive bin\signls.exe signls_${{ github.ref_name }}_${{ runner.os}}.zip - if: ${{ startsWith(matrix.os, 'windows') }} + sudo apt-get update + sudo apt-get install -y libasound2-dev - - name: Upload linux|mac artifact - uses: actions/upload-artifact@v4 - with: - name: signls_${{ github.sha }}_${{ runner.os}} - path: signls_${{ github.ref_name }}_${{ runner.os}}.tar.gz - if-no-files-found: error - if: ${{ !startsWith(matrix.os, 'windows') }} + - name: Install Windows cross toolchain (mingw-w64) + if: matrix.target == 'linux-amd64' + run: sudo apt-get install -y gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64 - - name: Upload windows artifact + - name: Run GoReleaser (release on tags, snapshot otherwise) + uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: >- + ${{ startsWith(github.ref, 'refs/tags/v') + && 'release --clean' + || 'release --snapshot --clean' }} + env: + GR_TARGET: ${{ matrix.target }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload dist artifacts uses: actions/upload-artifact@v4 with: - name: signls_${{ github.sha }}_${{ runner.os}} - path: signls_${{ github.ref_name }}_${{ runner.os}}.zip + name: dist-${{ matrix.target }} + path: | + dist/*.tar.gz + dist/*.zip + dist/checksums_*.txt if-no-files-found: error - if: ${{ startsWith(matrix.os, 'windows') }} + # On tags, collect every runner's archives and publish a single release. release: needs: build if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest steps: - - uses: actions/download-artifact@v4.1.7 + - name: Download all dist artifacts + uses: actions/download-artifact@v4 with: - name: signls_${{ github.sha }}_macOS + path: dist + pattern: dist-* + merge-multiple: true - - uses: actions/download-artifact@v4.1.7 - with: - name: signls_${{ github.sha }}_Linux + - name: Combine checksums + run: cat dist/checksums_*.txt > dist/checksums.txt - - uses: actions/download-artifact@v4.1.7 - with: - name: signls_${{ github.sha }}_Windows - - - name: Create release - uses: softprops/action-gh-release@v1 + - name: Create GitHub release + uses: softprops/action-gh-release@v2 with: + generate_release_notes: true files: | - signls_*.tar.gz - signls_*.zip - - # itchio-release: - # needs: release - # if: startsWith(github.ref, 'refs/tags/v') - # runs-on: ubuntu-latest - # strategy: - # matrix: - # os: [Linux, macOS, Windows] - # env: - # itchio_project: "emprcl/signls" - # steps: - # - uses: actions/download-artifact@v4.1.7 - # with: - # name: signls_${{ github.sha }}_${{ matrix.os }} - # - uses: robpc/itchio-upload-action@v1 - # with: - # path: signls_${{ github.sha }}_${{ matrix.os }} - # project: ${{ env.itchio_project }} - # channel: ${{ matrix.os }} - # version: ${{ github.ref_name}} - # api-key: ${{ secrets.ITCHIO_API_KEY }} + dist/*.tar.gz + dist/*.zip + dist/checksums.txt diff --git a/.gitignore b/.gitignore index 6a34cd6..1c9f993 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ bin +dist .coverage *.json *.log diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..62b9aee --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,94 @@ +version: 2 + +project_name: signls + +before: + hooks: + - go mod download + +# signls needs cgo: gomidi's rtmidi driver wraps C/C++ (ALSA on Linux, CoreMIDI +# on macOS, WinMM on Windows), so unlike a pure-Go project it can't be +# cross-compiled from a single runner. Each build below is gated by the +# GR_TARGET env var so a given CI runner only compiles the targets its toolchain +# supports (see .github/workflows/build.yml); the runners' dist outputs are then +# collected and published together as one GitHub release. +builds: + # Built natively on the linux-amd64 runner. + - id: linux-amd64 + binary: signls + main: . + flags: [-trimpath] + ldflags: + - -s -w -X main.version={{ .Version }} + env: + - CGO_ENABLED=1 + goos: [linux] + goarch: [amd64] + skip: '{{ ne (index .Env "GR_TARGET") "linux-amd64" }}' + + # Built natively on the linux-arm64 runner (covers 64-bit Raspberry Pi OS). + - id: linux-arm64 + binary: signls + main: . + flags: [-trimpath] + ldflags: + - -s -w -X main.version={{ .Version }} + env: + - CGO_ENABLED=1 + goos: [linux] + goarch: [arm64] + skip: '{{ ne (index .Env "GR_TARGET") "linux-arm64" }}' + + # Cross-compiled from the linux-amd64 runner with the mingw-w64 toolchain. + - id: windows-amd64 + binary: signls + main: . + flags: [-trimpath] + ldflags: + - -s -w -X main.version={{ .Version }} -extldflags=-static + env: + - CGO_ENABLED=1 + - CC=x86_64-w64-mingw32-gcc + - CXX=x86_64-w64-mingw32-g++ + goos: [windows] + goarch: [amd64] + skip: '{{ ne (index .Env "GR_TARGET") "linux-amd64" }}' + + # Both arches built on the macOS runner (native clang cross-compiles them). + - id: darwin + binary: signls + main: . + flags: [-trimpath] + ldflags: + - -s -w -X main.version={{ .Version }} + env: + - CGO_ENABLED=1 + goos: [darwin] + goarch: [amd64, arm64] + skip: '{{ ne (index .Env "GR_TARGET") "darwin" }}' + +archives: + - id: signls + # e.g. signls_1.2.3_linux_arm64.tar.gz, signls_1.2.3_windows_amd64.zip + name_template: >- + {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }} + formats: [tar.gz] + format_overrides: + - goos: windows + formats: [zip] + files: + - LICENSE + - README.md + +checksum: + # Each runner writes its own checksum file; the release workflow concatenates + # them into a single checksums.txt. + name_template: 'checksums_{{ index .Env "GR_TARGET" }}.txt' + +snapshot: + version_template: "{{ incpatch .Version }}-next" + +release: + # The GitHub release is assembled by the workflow once every runner's archives + # are collected, so goreleaser itself must not publish. + disable: true diff --git a/Makefile b/Makefile index 531e987..862a7d9 100644 --- a/Makefile +++ b/Makefile @@ -8,9 +8,20 @@ GOLANG_BUILD_OPTS += GOOS=$(GOLANG_OS) GOLANG_BUILD_OPTS += GOARCH=$(GOLANG_ARCH) GOLANG_BUILD_OPTS += CGO_ENABLED=$(CGO_ENABLED) GOLANG_LINT := $(BIN)/golangci-lint +GORELEASER := github.com/goreleaser/goreleaser/v2@latest ASEQDUMP_BIN := aseqdump -p 14:0 | ts '[%H:%M:%.S]' ASEQDUMP_NO_CLOCK_OPTS := | grep -v Clock +# GR_TARGET selects which goreleaser builds run (see .goreleaser.yaml). Default +# to the host so `make snapshot` builds a local binary. +HOST_OS := $(shell go env GOOS) +HOST_ARCH := $(shell go env GOARCH) +ifeq ($(HOST_OS),darwin) +GR_TARGET ?= darwin +else +GR_TARGET ?= $(HOST_OS)-$(HOST_ARCH) +endif + $(BIN): mkdir -p $(BIN) @@ -21,6 +32,10 @@ build: $(BIN) $(GOLANG_BUILD_OPTS) $(GOLANG_BIN) build -o $(BIN)/$(BIN_NAME) chmod +x $(BIN)/$(BIN_NAME) +# Build a local release snapshot (archives in dist/) for the host target. +snapshot: + GR_TARGET=$(GR_TARGET) $(GOLANG_BIN) run $(GORELEASER) release --snapshot --clean + checks: $(GOLANG_LINT) $(GOLANG_LINT) run ./... diff --git a/README.md b/README.md index a28676b..cd13391 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,18 @@ _Feel free to [open an issue](https://github.com/emprcl/signls/issues/new)._ ## Installation +### Supported platforms + +Prebuilt binaries are available for the following platforms: + +| OS | Architectures | +| ------- | ------------------------------------ | +| Linux | x86-64 (amd64), arm64 | +| macOS | Intel (amd64), Apple Silicon (arm64) | +| Windows | x86-64 (amd64) | + +> _The Linux arm64 build covers the 64-bit Raspberry Pi OS (Raspberry Pi 3 and later)._ + [Download the last release](https://github.com/emprcl/signls/releases) for your platform. Then: diff --git a/main.go b/main.go index c120bf3..2e9bf45 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,6 @@ package main import ( - _ "embed" "flag" "fmt" "log" @@ -10,24 +9,24 @@ import ( "signls/filesystem" "signls/midi" "signls/ui" - "strings" tea "github.com/charmbracelet/bubbletea" ) -//go:embed VERSION -var AppVersion string +// version is set at build time via -ldflags "-X main.version=...". It defaults +// to "dev" for local builds. +var version = "dev" func main() { configFile := flag.String("config", "", "config file to load or create (default: /signls/config.json)") bankFile := flag.String("bank", "", "bank file to store grids (default: /signls/default.json)") keyboard := flag.String("keyboard", "", "keyboard layout (qwerty, qwerty-mac, azerty, azerty-mac)") - version := flag.Bool("version", false, "print current version") + showVersion := flag.Bool("version", false, "print current version") debug := flag.Bool("debug", false, "enable debug mode") flag.Parse() - if *version { - fmt.Print(AppVersion) + if *showVersion { + fmt.Println(version) os.Exit(0) } @@ -42,7 +41,7 @@ func main() { bankPath = filesystem.DefaultPath("default.json") } - config := filesystem.NewConfiguration(configPath, strings.TrimSuffix(AppVersion, "\n"), *keyboard) + config := filesystem.NewConfiguration(configPath, version, *keyboard) midi, err := midi.New() if err != nil { From bdcf9184e97a837292f91f57d5a94bf105faea52 Mon Sep 17 00:00:00 2001 From: Xavier Godart Date: Fri, 12 Jun 2026 16:38:27 +0200 Subject: [PATCH 6/8] ci: upgrade actions --- .github/workflows/build.yml | 12 ++++++------ .github/workflows/checks.yml | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3dc0afb..1befe61 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,12 +27,12 @@ jobs: os: macos-latest runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 # full history & tags for version/changelog - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: "1.23" @@ -47,7 +47,7 @@ jobs: run: sudo apt-get install -y gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64 - name: Run GoReleaser (release on tags, snapshot otherwise) - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@v7 with: version: "~> v2" args: >- @@ -59,7 +59,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload dist artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: dist-${{ matrix.target }} path: | @@ -75,7 +75,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Download all dist artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: path: dist pattern: dist-* @@ -85,7 +85,7 @@ jobs: run: cat dist/checksums_*.txt > dist/checksums.txt - name: Create GitHub release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: generate_release_notes: true files: | diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index ff7cb8a..d927d87 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -15,12 +15,12 @@ jobs: name: checks runs-on: ubuntu-latest steps: - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version: 1.23 - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: golangci-lint - uses: golangci/golangci-lint-action@v4 + uses: golangci/golangci-lint-action@v9 with: args: -c .golangci.yml only-new-issues: true From 23ef352cc19b924d077e893f654e033b633dc793 Mon Sep 17 00:00:00 2001 From: Xavier Godart Date: Fri, 12 Jun 2026 16:38:43 +0200 Subject: [PATCH 7/8] style: fix linter warnings --- .golangci.yml | 2 +- core/field/grid.go | 3 +- core/field/grid_concurrency_test.go | 5 +-- core/field/grid_serialization.go | 1 + core/field/grid_test.go | 3 +- core/music/key_value.go | 3 +- core/music/meta/bank.go | 1 + core/music/meta/tempo.go | 1 + core/music/note.go | 3 +- core/node/cycle.go | 1 + core/node/dice.go | 3 +- core/node/emitter.go | 3 +- core/node/euclid.go | 1 + core/node/toll.go | 1 + core/theory/theory.go | 1 + filesystem/bank.go | 5 +-- filesystem/path_test.go | 6 ++-- main.go | 1 + ui/control.go | 5 +-- ui/node.go | 1 + ui/param/bank_cmd.go | 3 +- ui/param/cc.go | 5 +-- ui/param/channel.go | 3 +- ui/param/default_device.go | 1 + ui/param/destination.go | 5 +-- ui/param/device.go | 1 + ui/param/key.go | 3 +- ui/param/length.go | 3 +- ui/param/offset.go | 3 +- ui/param/probability.go | 3 +- ui/param/repeat.go | 3 +- ui/param/root_cmd.go | 1 + ui/param/scale_cmd.go | 1 + ui/param/steps.go | 3 +- ui/param/tempo_cmd.go | 3 +- ui/param/threshold.go | 3 +- ui/param/triggers.go | 3 +- ui/param/velocity.go | 3 +- ui/ui.go | 55 ++++++++++++++++++----------- 39 files changed, 100 insertions(+), 55 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index f1b2739..df2fc88 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -23,7 +23,7 @@ formatters: - gofumpt settings: gofumpt: - module-path: runal + module-path: signls exclusions: generated: lax paths: diff --git a/core/field/grid.go b/core/field/grid.go index 743ea28..77b1a95 100644 --- a/core/field/grid.go +++ b/core/field/grid.go @@ -1,13 +1,14 @@ package field import ( + "sync" + "signls/core/common" "signls/core/music" "signls/core/music/meta" "signls/core/node" "signls/core/theory" "signls/midi" - "sync" ) const ( diff --git a/core/field/grid_concurrency_test.go b/core/field/grid_concurrency_test.go index b86cb8d..2b07696 100644 --- a/core/field/grid_concurrency_test.go +++ b/core/field/grid_concurrency_test.go @@ -2,13 +2,14 @@ package field import ( "path/filepath" + "sync" + "testing" + "signls/core/common" "signls/core/music" "signls/core/node" "signls/filesystem" "signls/midi" - "sync" - "testing" ) // TestGridConcurrentAccess exercises the grid the way the running app does: the diff --git a/core/field/grid_serialization.go b/core/field/grid_serialization.go index 6a2e216..7064ad3 100644 --- a/core/field/grid_serialization.go +++ b/core/field/grid_serialization.go @@ -2,6 +2,7 @@ package field import ( "log" + "signls/core/common" "signls/core/music" "signls/core/node" diff --git a/core/field/grid_test.go b/core/field/grid_test.go index 65d981c..a19242e 100644 --- a/core/field/grid_test.go +++ b/core/field/grid_test.go @@ -2,10 +2,11 @@ package field import ( "fmt" + "testing" + "signls/core/common" "signls/core/node" "signls/midi" - "testing" ) var benchmarks = []struct { diff --git a/core/music/key_value.go b/core/music/key_value.go index 4ed9c0f..b84b58f 100644 --- a/core/music/key_value.go +++ b/core/music/key_value.go @@ -3,9 +3,10 @@ package music import ( "math" "math/rand" + "time" + "signls/core/theory" "signls/midi" - "time" ) const ( diff --git a/core/music/meta/bank.go b/core/music/meta/bank.go index 97fc3db..b4dbd2e 100644 --- a/core/music/meta/bank.go +++ b/core/music/meta/bank.go @@ -2,6 +2,7 @@ package meta import ( "fmt" + "signls/core/common" ) diff --git a/core/music/meta/tempo.go b/core/music/meta/tempo.go index e0d8842..7d82382 100644 --- a/core/music/meta/tempo.go +++ b/core/music/meta/tempo.go @@ -2,6 +2,7 @@ package meta import ( "fmt" + "signls/core/common" ) diff --git a/core/music/note.go b/core/music/note.go index f1ef568..34a7a6d 100644 --- a/core/music/note.go +++ b/core/music/note.go @@ -3,11 +3,12 @@ package music import ( "fmt" "math/rand" + "time" + "signls/core/common" "signls/core/music/meta" "signls/core/theory" "signls/midi" - "time" ) // Constants defining default values for note properties and their limits. diff --git a/core/node/cycle.go b/core/node/cycle.go index 1325fb4..ac93ec8 100644 --- a/core/node/cycle.go +++ b/core/node/cycle.go @@ -2,6 +2,7 @@ package node import ( "math" + "signls/core/common" "signls/core/music" "signls/midi" diff --git a/core/node/dice.go b/core/node/dice.go index 765096b..9728296 100644 --- a/core/node/dice.go +++ b/core/node/dice.go @@ -3,10 +3,11 @@ package node import ( "math" "math/rand" + "time" + "signls/core/common" "signls/core/music" "signls/midi" - "time" ) type DiceEmitter struct { diff --git a/core/node/emitter.go b/core/node/emitter.go index a56805c..58e08ca 100644 --- a/core/node/emitter.go +++ b/core/node/emitter.go @@ -2,10 +2,11 @@ package node import ( "fmt" + "unicode/utf8" + "signls/core/common" "signls/core/music" "signls/core/theory" - "unicode/utf8" ) type Emitter struct { diff --git a/core/node/euclid.go b/core/node/euclid.go index 0ab78c6..1b377f6 100644 --- a/core/node/euclid.go +++ b/core/node/euclid.go @@ -2,6 +2,7 @@ package node import ( "fmt" + "signls/core/common" "signls/core/music" "signls/core/theory" diff --git a/core/node/toll.go b/core/node/toll.go index 3bd77b3..095eb05 100644 --- a/core/node/toll.go +++ b/core/node/toll.go @@ -2,6 +2,7 @@ package node import ( "math" + "signls/core/common" "signls/core/music" "signls/midi" diff --git a/core/theory/theory.go b/core/theory/theory.go index 3224d73..2c42b02 100644 --- a/core/theory/theory.go +++ b/core/theory/theory.go @@ -2,6 +2,7 @@ package theory import ( "math" + "signls/midi" ) diff --git a/filesystem/bank.go b/filesystem/bank.go index d7ddb03..fec293f 100644 --- a/filesystem/bank.go +++ b/filesystem/bank.go @@ -9,12 +9,13 @@ import ( "log" "os" "path/filepath" + "strings" + "sync" + "signls/core/common" "signls/core/music" "signls/core/music/meta" "signls/core/theory" - "strings" - "sync" ) const ( diff --git a/filesystem/path_test.go b/filesystem/path_test.go index 63021b0..52486ec 100644 --- a/filesystem/path_test.go +++ b/filesystem/path_test.go @@ -9,11 +9,9 @@ import ( // redirectConfigDir points the config base at a temp dir via XDG_CONFIG_HOME, // which configBase honors on every platform. -func redirectConfigDir(t *testing.T) string { +func redirectConfigDir(t *testing.T) { t.Helper() - dir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", dir) - return dir + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) } func TestAppDirCreatesUnderConfigDir(t *testing.T) { diff --git a/main.go b/main.go index 2e9bf45..67bd960 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "os" + "signls/core/field" "signls/filesystem" "signls/midi" diff --git a/ui/control.go b/ui/control.go index 6bcff20..f25d97f 100644 --- a/ui/control.go +++ b/ui/control.go @@ -2,6 +2,7 @@ package ui import ( "fmt" + "signls/core/common" "signls/filesystem" "signls/ui/param" @@ -214,10 +215,6 @@ func (m mainModel) modeName() string { } } -func (m mainModel) selectedNode() common.Node { - return m.grid.Nodes()[m.cursorY][m.cursorX] -} - func (m mainModel) selectedEmitters() []common.Node { nodes := []common.Node{} for y := m.cursorY; y <= m.selectionY; y++ { diff --git a/ui/node.go b/ui/node.go index 27497b5..3bed0de 100644 --- a/ui/node.go +++ b/ui/node.go @@ -2,6 +2,7 @@ package ui import ( "log" + "signls/core/field" "signls/core/node" "signls/ui/param" diff --git a/ui/param/bank_cmd.go b/ui/param/bank_cmd.go index 047adaa..01c245e 100644 --- a/ui/param/bank_cmd.go +++ b/ui/param/bank_cmd.go @@ -2,10 +2,11 @@ package param import ( "fmt" + "strconv" + "signls/core/common" "signls/core/music" "signls/ui/util" - "strconv" ) const ( diff --git a/ui/param/cc.go b/ui/param/cc.go index 0ed913c..c2c7d2a 100644 --- a/ui/param/cc.go +++ b/ui/param/cc.go @@ -2,12 +2,13 @@ package param import ( "fmt" + "strconv" + "strings" + "signls/core/common" "signls/core/music" "signls/midi" "signls/ui/util" - "strconv" - "strings" ) type CC struct { diff --git a/ui/param/channel.go b/ui/param/channel.go index e2e4c59..e2be5ef 100644 --- a/ui/param/channel.go +++ b/ui/param/channel.go @@ -2,10 +2,11 @@ package param import ( "fmt" + "strconv" + "signls/core/common" "signls/core/music" "signls/ui/util" - "strconv" ) type Channel struct { diff --git a/ui/param/default_device.go b/ui/param/default_device.go index 8e3446c..e6f6296 100644 --- a/ui/param/default_device.go +++ b/ui/param/default_device.go @@ -2,6 +2,7 @@ package param import ( "fmt" + "signls/core/field" ) diff --git a/ui/param/destination.go b/ui/param/destination.go index 01201ba..9f789d6 100644 --- a/ui/param/destination.go +++ b/ui/param/destination.go @@ -2,10 +2,11 @@ package param import ( "fmt" - "signls/core/common" - "signls/core/node" "strconv" "strings" + + "signls/core/common" + "signls/core/node" ) type Destination struct { diff --git a/ui/param/device.go b/ui/param/device.go index b0c8e6b..442678e 100644 --- a/ui/param/device.go +++ b/ui/param/device.go @@ -2,6 +2,7 @@ package param import ( "fmt" + "signls/core/common" "signls/core/music" ) diff --git a/ui/param/key.go b/ui/param/key.go index 9c8d402..ec6a3cb 100644 --- a/ui/param/key.go +++ b/ui/param/key.go @@ -2,11 +2,12 @@ package param import ( "fmt" + "time" + "signls/core/common" "signls/core/music" "signls/core/theory" "signls/ui/util" - "time" ) type KeyMode uint8 diff --git a/ui/param/length.go b/ui/param/length.go index a3a83a2..fdc10c8 100644 --- a/ui/param/length.go +++ b/ui/param/length.go @@ -2,10 +2,11 @@ package param import ( "fmt" + "strconv" + "signls/core/common" "signls/core/music" "signls/ui/util" - "strconv" ) const ( diff --git a/ui/param/offset.go b/ui/param/offset.go index 3e1c07f..cff96a9 100644 --- a/ui/param/offset.go +++ b/ui/param/offset.go @@ -2,10 +2,11 @@ package param import ( "fmt" + "strconv" + "signls/core/common" "signls/core/node" "signls/ui/util" - "strconv" ) type Offset struct { diff --git a/ui/param/probability.go b/ui/param/probability.go index 8e521f0..05ae2e5 100644 --- a/ui/param/probability.go +++ b/ui/param/probability.go @@ -2,9 +2,10 @@ package param import ( "fmt" + "strconv" + "signls/core/common" "signls/core/music" - "strconv" ) const ( diff --git a/ui/param/repeat.go b/ui/param/repeat.go index a44b725..e7f4838 100644 --- a/ui/param/repeat.go +++ b/ui/param/repeat.go @@ -3,10 +3,11 @@ package param import ( "fmt" "math" + "strconv" + "signls/core/common" "signls/core/node" "signls/ui/util" - "strconv" ) type Repeat struct { diff --git a/ui/param/root_cmd.go b/ui/param/root_cmd.go index 863cfbf..da22634 100644 --- a/ui/param/root_cmd.go +++ b/ui/param/root_cmd.go @@ -2,6 +2,7 @@ package param import ( "fmt" + "signls/core/common" "signls/core/music" "signls/ui/util" diff --git a/ui/param/scale_cmd.go b/ui/param/scale_cmd.go index c5e1e61..9f59fe5 100644 --- a/ui/param/scale_cmd.go +++ b/ui/param/scale_cmd.go @@ -2,6 +2,7 @@ package param import ( "fmt" + "signls/core/common" "signls/core/music" "signls/ui/util" diff --git a/ui/param/steps.go b/ui/param/steps.go index e6debba..e0ae279 100644 --- a/ui/param/steps.go +++ b/ui/param/steps.go @@ -2,10 +2,11 @@ package param import ( "fmt" + "strconv" + "signls/core/common" "signls/core/node" "signls/ui/util" - "strconv" ) type Steps struct { diff --git a/ui/param/tempo_cmd.go b/ui/param/tempo_cmd.go index be9a675..2d7bef1 100644 --- a/ui/param/tempo_cmd.go +++ b/ui/param/tempo_cmd.go @@ -2,10 +2,11 @@ package param import ( "fmt" + "strconv" + "signls/core/common" "signls/core/music" "signls/ui/util" - "strconv" ) const ( diff --git a/ui/param/threshold.go b/ui/param/threshold.go index 5f71d83..8655b2e 100644 --- a/ui/param/threshold.go +++ b/ui/param/threshold.go @@ -3,10 +3,11 @@ package param import ( "fmt" "math" + "strconv" + "signls/core/common" "signls/core/node" "signls/ui/util" - "strconv" ) type Threshold struct { diff --git a/ui/param/triggers.go b/ui/param/triggers.go index 9608ad4..c89990d 100644 --- a/ui/param/triggers.go +++ b/ui/param/triggers.go @@ -2,10 +2,11 @@ package param import ( "fmt" + "strconv" + "signls/core/common" "signls/core/node" "signls/ui/util" - "strconv" ) const ( diff --git a/ui/param/velocity.go b/ui/param/velocity.go index 8b01ce8..41f09c1 100644 --- a/ui/param/velocity.go +++ b/ui/param/velocity.go @@ -2,10 +2,11 @@ package param import ( "fmt" + "strconv" + "signls/core/common" "signls/core/music" "signls/ui/util" - "strconv" ) type Velocity struct { diff --git a/ui/ui.go b/ui/ui.go index 474d547..8bd0ff1 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -2,11 +2,12 @@ package ui import ( "fmt" + "time" + "signls/core/field" "signls/filesystem" "signls/ui/param" "signls/ui/util" - "time" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" @@ -120,11 +121,10 @@ func save(m mainModel) tea.Cmd { } // requestSave schedules a debounced save instead of writing on every edit, so a -// burst of edits coalesces into a single disk write. It returns no command; the -// write happens on the saver's timer goroutine. -func (m mainModel) requestSave() tea.Cmd { +// burst of edits coalesces into a single disk write. The write happens on the +// saver's timer goroutine. +func (m mainModel) requestSave() { m.saver.request() - return nil } func (m mainModel) Init() tea.Cmd { @@ -203,7 +203,8 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { dir := m.keymap.Direction(msg) if m.mode == EDIT || m.mode == CONFIG { m.handleParamAltEdit(dir) - return m, m.requestSave() + m.requestSave() + return m, nil } m.selectionX, m.selectionY = moveCursor( dir, 1, m.selectionX, m.selectionY, @@ -217,10 +218,12 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.grid.Write(func() { param.NewDirection(m.selectedEmitters()).SetFromKeyString(dir) }) - return m, m.requestSave() + m.requestSave() + return m, nil } m.handleParamEdit(dir) - return m, m.requestSave() + m.requestSave() + return m, nil case key.Matches(msg, m.keymap.AddBang, m.keymap.AddSpread, m.keymap.AddCycle, m.keymap.AddDice, m.keymap.AddToll, m.keymap.AddEuclid, m.keymap.AddZone, m.keymap.AddPass, m.keymap.AddHole): m.grid.AddNodeFromSymbol(m.keymap.EmitterSymbol(msg), m.cursorX, m.cursorY) newParams := param.NewParamsForNodes(m.grid, m.selectedEmitters()) @@ -231,14 +234,17 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.param = 0 } m.params = newParams - return m, m.requestSave() + m.requestSave() + return m, nil case key.Matches(msg, m.keymap.MuteNode): m.grid.ToggleNodeMutes(m.cursorX, m.cursorY, m.selectionX, m.selectionY) - return m, m.requestSave() + m.requestSave() + return m, nil case key.Matches(msg, m.keymap.MuteAllNode): m.grid.SetAllNodeMutes(!m.mute) m.mute = !m.mute - return m, m.requestSave() + m.requestSave() + return m, nil case key.Matches(msg, m.keymap.RemoveNode): if m.mode == BANK { m.bank.ClearGrid(m.selectedGrid) @@ -246,7 +252,8 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.mode = MOVE m.grid.RemoveNodes(m.cursorX, m.cursorY, m.selectionX, m.selectionY) - return m, m.requestSave() + m.requestSave() + return m, nil case key.Matches(msg, m.keymap.EditNode): if m.mode == BANK { m.mode = MOVE @@ -285,31 +292,37 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } param.Get("root", m.gridParams).Up() - return m, m.requestSave() + m.requestSave() + return m, nil case key.Matches(msg, m.keymap.RootNoteDown): if m.mode == EDIT { return m, nil } param.Get("root", m.gridParams).Down() - return m, m.requestSave() + m.requestSave() + return m, nil case key.Matches(msg, m.keymap.ScaleUp): if m.mode == EDIT { return m, nil } param.Get("scale", m.gridParams).Up() - return m, m.requestSave() + m.requestSave() + return m, nil case key.Matches(msg, m.keymap.ScaleDown): if m.mode == EDIT { return m, nil } param.Get("scale", m.gridParams).Down() - return m, m.requestSave() + m.requestSave() + return m, nil case key.Matches(msg, m.keymap.TempoUp): m.grid.SetTempo(m.grid.Tempo() + 1) - return m, m.requestSave() + m.requestSave() + return m, nil case key.Matches(msg, m.keymap.TempoDown): m.grid.SetTempo(m.grid.Tempo() - 1) - return m, m.requestSave() + m.requestSave() + return m, nil case key.Matches(msg, m.keymap.Configuration): m.mode = m.toggleMode(CONFIG) m.params = param.NewParamsForMidi(m.grid) @@ -340,7 +353,8 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.grid.Paste(m.cursorX, m.cursorY, m.selectionX, m.selectionY) m.params = param.NewParamsForNodes(m.grid, m.selectedEmitters()) - return m, m.requestSave() + m.requestSave() + return m, nil case key.Matches(msg, m.keymap.Cancel): m.mode = MOVE m.selectionX = m.cursorX @@ -352,7 +366,8 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.selectionX, m.selectionY = m.cursorX, m.cursorY m.grid.Resize(m.viewport.Width, m.viewport.Height) m.viewport.Update(m.cursorX, m.cursorY, m.grid.Width, m.grid.Height) - return m, m.requestSave() + m.requestSave() + return m, nil case key.Matches(msg, m.keymap.Help): m.help.ShowAll = !m.help.ShowAll return m, tea.ClearScreen From aaa985c9769dd493db8b37599a2e9b14d960e248 Mon Sep 17 00:00:00 2001 From: Xavier Godart Date: Fri, 12 Jun 2026 16:49:43 +0200 Subject: [PATCH 8/8] ci: install alsa headers in check job --- .github/workflows/checks.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index d927d87..8c5d797 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -18,6 +18,10 @@ jobs: - uses: actions/setup-go@v6 with: go-version: 1.23 + - name: Install ALSA headers + run: | + sudo apt-get update + sudo apt-get install -y libasound2-dev - uses: actions/checkout@v6 - name: golangci-lint uses: golangci/golangci-lint-action@v9