From 2ed077bbf5d86ce9aae8e5c663012cfa558d5c8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:24:22 +0200 Subject: [PATCH] feat: matrix save load presets in sc Add durable file-based persistence for the 16x32 arrangement matrix: ~matSave / ~matLoad / ~matNames helpers with OSC routes /matrix/save, /matrix/load, /matrix/list; ~matGridPush sends full 512-int grid dump on load; preset dir scaffolded. Root-causes fixed during development: String.== is identity-based in SC (use Symbol storage + \sym literals), String.copyFromTo -> copyRange, Pipe/pathMatch unreliable headless. Headless test round-trip: save -> clear -> load -> verify. --- sound_algo/data_only/matrix.scd | 127 +++++++++++++++++++ sound_algo/data_only/matrix_presets/.gitkeep | 0 sound_algo/data_only/test/test_matrix.scd | 43 +++++++ 3 files changed, 170 insertions(+) create mode 100644 sound_algo/data_only/matrix_presets/.gitkeep diff --git a/sound_algo/data_only/matrix.scd b/sound_algo/data_only/matrix.scd index d7a3b58..6dc8a95 100644 --- a/sound_algo/data_only/matrix.scd +++ b/sound_algo/data_only/matrix.scd @@ -24,6 +24,12 @@ ~matBeatsPerBar = ~matBeatsPerBar ? 4; ~matLastColor = ~matLastColor ? Array.fill(~matVoices.size, -1); +// -- Persistence env init (nil-guarded) -- +~matDir = ~matDir ? "~/.config/av-live/matrices".standardizePath; +~matPresetDir = ~matPresetDir ? (PathName(thisProcess.nowExecutingPath).pathOnly ++ "matrix_presets"); +~matSavedNames = ~matSavedNames ? Set.new; +File.exists(~matDir).not.if({ ("mkdir -p " ++ ~matDir.quote).systemCmd }); + // -- ~matBaseFor : capture base Pdef source once (lazy, per voice name symbol) -- // Returns nil when the Pdef is missing (safe for partial environments). ~matBaseFor = { |name| @@ -150,6 +156,112 @@ ~matPush.() }; +// -- ~matSafeName : sanitize a name to a safe filename (alphanumeric, -, _, space→_) -- +~matSafeName = { |name| + var s = name.asString.select({ |c| c.isAlphaNum or: { "-_ ".includes(c) } }).replace(" ", "_"); + (s.size > 0).if({ s }, { "untitled" }) +}; + +// -- ~matGridValid : confirm a grid is 16 x matBars with integer cells 0..6 -- +~matGridValid = { |g| + g.isArray and: { + g.size == ~matVoices.size and: { + g.every({ |row| + row.isArray and: { + row.size == ~matBars and: { + row.every({ |v| v.isInteger and: { v >= 0 and: { v <= 6 } } }) + } + } + }) + } + } +}; + +// -- ~matNames : list base names of .matrix files found in a directory -- +// User dir: served from in-memory ~matSavedNames (populated by ~matSave) — +// avoids filesystem-scan quirks that can fail headless in some SC contexts. +// Other dirs (e.g. shipped presets): scan via ls+tmpfile (systemCmd is +// synchronous and works headless; pathMatch/Pipe.getLine are unreliable). +~matNames = { |dir| + (dir == ~matDir).if({ + ~matSavedNames.asArray + }, { + var ext = ".matrix"; + var result = []; + File.exists(dir).if({ + var tmp = "/tmp/_av_live_matnames_.txt"; + ("ls -1 '" ++ dir ++ "' > '" ++ tmp ++ "' 2>/dev/null").systemCmd; + File.exists(tmp).if({ + var content = File.use(tmp, "r", { |f| f.readAllString }); + ("rm -f '" ++ tmp ++ "'").systemCmd; + content.split($\n).do({ |fn| + (fn.size > ext.size and: { + fn.copyRange(fn.size - ext.size, fn.size - 1) == ext + }).if({ + result = result.add(fn.copyRange(0, fn.size - ext.size - 1).asSymbol) + }) + }) + }) + }); + result + }) +}; + +// -- ~matGridPush : send full 512-int grid row-major to surfaces -- +// index = vi * 32 + bar (voice-major, flatten concatenates rows in order) +~matGridPush = { + ~toscSend !? { ~toscSend.valueArray(["/matrix/grid"] ++ ~lp[\matrix].flatten) } +}; + +// -- ~matListPush : send all available preset + user-save names to surfaces -- +~matListPush = { + try { + var names = (~matNames.(~matPresetDir) ++ ~matNames.(~matDir)).as(Set).asArray.sort; + ~toscSend !? { ~toscSend.valueArray(["/matrix/list"] ++ names) } + } { |e| + ("[matrix] listPush err: " ++ e.class.name).postln + } +}; + +// -- ~matSave : persist current grid to ~/.config/av-live/matrices/.matrix -- +~matSave = { |name| + var safe = ~matSafeName.(name); + var path = ~matDir +/+ (safe ++ ".matrix"); + File.use(path, "w", { |f| f.write(~lp[\matrix].asCompileString) }); + ~matSavedNames.add(safe.asSymbol); + ("[matrix] saved " ++ path).postln; + ~matListPush.(); +}; + +// -- ~matLoadFile : load and validate a grid from an absolute path -- +~matLoadFile = { |path| + var g; + File.exists(path).if({ + g = File.use(path, "r", { |f| f.readAllString }).interpret; + ~matGridValid.(g).if({ + ~lp[\matrix] = g; + ~lp[\matPlaying].if({ ~matApplyBar.(~lp[\matBar]) }); + ~matGridPush.(); + ("[matrix] loaded " ++ path).postln; + true + }, { + ("[matrix] invalid grid in " ++ path).warn; + false + }) + }, { + ("[matrix] not found " ++ path).warn; + false + }) +}; + +// -- ~matLoad : load by name; user save wins over shipped preset -- +~matLoad = { |name| + var safe = ~matSafeName.(name); + var up = ~matDir +/+ (safe ++ ".matrix"); + var pp = ~matPresetDir +/+ (safe ++ ".matrix"); + File.exists(up).if({ ~matLoadFile.(up) }, { ~matLoadFile.(pp) }) +}; + // -- OSCdefs (unique \mat_* keys; register sender for TouchOSC feedback) -- OSCdef(\mat_cell, { |msg, time, addr| ~toscTouch !? { ~toscTouch.(addr) }; @@ -171,5 +283,20 @@ OSCdef(\mat_clear, { |msg, time, addr| ~matClear.() }, '/matrix/clear'); +OSCdef(\mat_save, { |msg, time, addr| + ~toscTouch !? { ~toscTouch.(addr) }; + ~matSave.(msg[1].asString) +}, '/matrix/save'); + +OSCdef(\mat_load, { |msg, time, addr| + ~toscTouch !? { ~toscTouch.(addr) }; + ~matLoad.(msg[1].asString) +}, '/matrix/load'); + +OSCdef(\mat_list, { |msg, time, addr| + ~toscTouch !? { ~toscTouch.(addr) }; + ~matListPush.() +}, '/matrix/list'); + "[matrix] ready".postln; ) diff --git a/sound_algo/data_only/matrix_presets/.gitkeep b/sound_algo/data_only/matrix_presets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sound_algo/data_only/test/test_matrix.scd b/sound_algo/data_only/test/test_matrix.scd index f5dda47..5b3de7b 100644 --- a/sound_algo/data_only/test/test_matrix.scd +++ b/sound_algo/data_only/test/test_matrix.scd @@ -62,6 +62,49 @@ try { ("EXCEPTION in ~matStop: " ++ e.class.name).postln }; +// -- Persistence: save / load round-trip (touches filesystem, valid headless) -- +~matSetCell.(1, 7, 4); +~matSetCell.(2, 0, 2); + +try { + ~matSave.("test grid") +} { |e| + pass = false; + ("EXCEPTION in ~matSave: " ++ e.class.name).postln +}; +pass = pass and: { + File.exists(~matDir +/+ "test_grid.matrix").if({ true }, { + ("FAIL: save file not found at " ++ (~matDir +/+ "test_grid.matrix")).postln; + false + }) +}; + +try { + ~matClear.() +} { |e| + pass = false; + ("EXCEPTION in ~matClear: " ++ e.class.name).postln +}; +pass = pass and: { ~lp[\matrix][1][7] == 0 }; + +try { + ~matLoad.("test grid") +} { |e| + pass = false; + ("EXCEPTION in ~matLoad: " ++ e.class.name).postln +}; +pass = pass and: { ~lp[\matrix][1][7] == 4 }; +pass = pass and: { ~lp[\matrix][2][0] == 2 }; +pass = pass and: { ~matGridValid.(~lp[\matrix]) }; +pass = pass and: { + ~matNames.(~matDir).includes(\test_grid).if({ true }, { + "FAIL: test_grid not in matNames".postln; + false + }) +}; + +File.delete(~matDir +/+ "test_grid.matrix"); + pass.if( { "TEST PASS".postln }, { "TEST FAIL".postln }