├── _config.yml ├── lib ├── tests │ ├── helpers │ │ ├── mocks │ │ │ ├── norns_param_state_handler_mock.lua │ │ │ ├── device_map_mock.lua │ │ │ ├── channel_sequence_page_mock.lua │ │ │ ├── channel_edit_page_mock.lua │ │ │ ├── sinfonion_mock.lua │ │ │ ├── m_midi_mock.lua │ │ │ ├── norns_mock.lua │ │ │ ├── params_mock.lua │ │ │ ├── scheduler_mock.lua │ │ │ └── channel_edit_page_ui_mock.lua │ │ └── globals.lua │ ├── lib │ │ ├── integration_tests │ │ │ └── channel_tests.lua │ │ ├── m_clock_performance_tests.lua │ │ └── program_tests.lua │ ├── run_tests.lua │ └── run_tests_windows.lua ├── config │ ├── dt_poly_cv.json │ ├── vermona_drm_bd.json │ ├── vermona_drm_sd.json │ ├── vermona_drm_clap.json │ ├── vermona_drm_hh_1.json │ ├── vermona_drm_hh_2.json │ ├── vermona_drm_multi.json │ ├── vermona_drm_drum_1.json │ ├── vermona_drm_drum_2.json │ ├── te_op_1.json │ ├── dt_m_sample.json │ └── ex_m_sample.json ├── pages │ ├── velocity_edit_page │ │ └── velocity_edit_page_ui.lua │ ├── note_edit_page │ │ └── note_edit_page_ui.lua │ ├── pages.lua │ ├── trigger_edit_page │ │ └── trigger_edit_page_ui.lua │ ├── scale_edit_page │ │ └── scale_edit_page.lua │ ├── song_edit_page │ │ └── song_edit_page.lua │ └── channel_edit_page │ │ ├── channel_edit_page_ui_refreshers.lua │ │ └── channel_edit_page_ui_handlers.lua ├── controls │ ├── paint_button.lua │ ├── button.lua │ ├── fade_button.lua │ ├── vertical_fader.lua │ ├── fader.lua │ └── sequencer.lua ├── grid_abstraction.lua ├── ui_components │ ├── tooltip.lua │ ├── page.lua │ ├── grid_viewer.lua │ ├── pages.lua │ ├── save_confirm.lua │ ├── control_scroll_selector.lua │ ├── list_selector.lua │ ├── value_selector.lua │ ├── vertical_scroll_selector.lua │ ├── memory_history_navigator.lua │ └── dial.lua ├── sinfonion_harmonic_sync.lua ├── devices │ ├── norns_param_state_handler.lua │ ├── nb_device_param_maps.lua │ └── param_manager.lua ├── draw.lua ├── scheduler.lua ├── ui.lua ├── helpers │ └── drum_ops.lua ├── press.lua ├── recorder.lua ├── pattern.lua └── clock │ └── divisions.lua ├── .gitmodules ├── images └── Sinfonion │ └── inverter.png ├── .gitattributes ├── test.sh ├── test.bat ├── .github └── workflows │ └── main.yml ├── .gitignore └── scripts └── process_midi_csvs.py /_config.yml: -------------------------------------------------------------------------------- 1 | name: "Mosaic" 2 | title: null -------------------------------------------------------------------------------- /lib/tests/helpers/mocks/norns_param_state_handler_mock.lua: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/nb"] 2 | path = lib/nb 3 | url = https://github.com/sixolet/nb.git 4 | -------------------------------------------------------------------------------- /images/Sinfonion/inverter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvertnormality/mosaic/HEAD/images/Sinfonion/inverter.png -------------------------------------------------------------------------------- /lib/tests/helpers/mocks/device_map_mock.lua: -------------------------------------------------------------------------------- 1 | device_map = {} 2 | device_map.get_device = function () 3 | return {} 4 | end 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto eol=lf 3 | *.{cmd,[cC][mM][dD]} text eol=crlf 4 | *.{bat,[bB][aA][tT]} text eol=crlf 5 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Change directory to the directory containing the Lua script 4 | cd "./lib/tests/" 5 | 6 | # Run the Lua script 7 | lua ./run_tests.lua -p test_$1 -------------------------------------------------------------------------------- /lib/tests/helpers/mocks/channel_sequence_page_mock.lua: -------------------------------------------------------------------------------- 1 | song_edit_page = {} 2 | 3 | song_edit_page.refresh = function() 4 | table.insert(song_edit_page_refresh_events, true) 5 | end -------------------------------------------------------------------------------- /lib/tests/helpers/mocks/channel_edit_page_mock.lua: -------------------------------------------------------------------------------- 1 | channel_edit_page = {} 2 | 3 | channel_edit_page.refresh = function() 4 | table.insert(channel_edit_page_refresh_events, true) 5 | end -------------------------------------------------------------------------------- /test.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | REM Change directory to the directory containing the Lua script 4 | cd ".\lib\tests\" 5 | 6 | REM Run the Lua script with a parameter 7 | lua .\run_tests_windows.lua -p test_%1 -------------------------------------------------------------------------------- /lib/config/dt_poly_cv.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "params": [ 3 | ], 4 | "name": "DT Poly CV", 5 | "type": "midi", 6 | "value": 9, 7 | "polyphonic": true, 8 | "map_params_automatically": false, 9 | "id": "dt-poly-cv" 10 | }] 11 | -------------------------------------------------------------------------------- /lib/tests/helpers/mocks/sinfonion_mock.lua: -------------------------------------------------------------------------------- 1 | 2 | sinfonion = {} 3 | 4 | sinfonion.set_root_note = function() end 5 | sinfonion.set_degree_nr = function() end 6 | sinfonion.set_mode_nr = function() end 7 | sinfonion.set_transposition = function() end -------------------------------------------------------------------------------- /lib/config/vermona_drm_bd.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "params": [], 3 | "type": "midi", 4 | "unique": true, 5 | "polyphonic": false, 6 | "value": 8, 7 | "default_midi_device": 1, 8 | "default_midi_channel": 10, 9 | "name": "DRM BD", 10 | "id": "drm-bd", 11 | "fixed_note": 36 12 | }] -------------------------------------------------------------------------------- /lib/config/vermona_drm_sd.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "params": [], 3 | "type": "midi", 4 | "unique": true, 5 | 6 | "polyphonic": false, 7 | "value": 12, 8 | "default_midi_device": 1, 9 | "default_midi_channel": 10, 10 | "name": "DRM Snare", 11 | "id": "drm-sd", 12 | "fixed_note": 38 13 | }] -------------------------------------------------------------------------------- /lib/config/vermona_drm_clap.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "params": [], 3 | "type": "midi", 4 | "unique": true, 5 | 6 | "polyphonic": false, 7 | "value": 15, 8 | "default_midi_device": 1, 9 | "default_midi_channel": 10, 10 | "name": "DRM Clap", 11 | "id": "drm-clap", 12 | "fixed_note": 39 13 | }] -------------------------------------------------------------------------------- /lib/config/vermona_drm_hh_1.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "params": [], 3 | "type": "midi", 4 | "unique": true, 5 | 6 | "polyphonic": false, 7 | "value": 13, 8 | "default_midi_device": 1, 9 | "default_midi_channel": 10, 10 | "name": "DRM HH 1", 11 | "id": "drm-hh1", 12 | "fixed_note": 44 13 | }] -------------------------------------------------------------------------------- /lib/config/vermona_drm_hh_2.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "params": [], 3 | "type": "midi", 4 | "unique": true, 5 | 6 | "polyphonic": false, 7 | "value": 14, 8 | "default_midi_device": 1, 9 | "default_midi_channel": 10, 10 | "name": "DRM HH 2", 11 | "id": "drm-hh2", 12 | "fixed_note": 49 13 | }] -------------------------------------------------------------------------------- /lib/config/vermona_drm_multi.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "params": [], 3 | "type": "midi", 4 | "unique": true, 5 | 6 | "polyphonic": false, 7 | "value": 11, 8 | "default_midi_device": 1, 9 | "default_midi_channel": 10, 10 | "name": "DRM Multi", 11 | "id": "drm-multi", 12 | "fixed_note": 56 13 | }] -------------------------------------------------------------------------------- /lib/config/vermona_drm_drum_1.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "params": [], 3 | "type": "midi", 4 | "unique": true, 5 | 6 | "polyphonic": false, 7 | "value": 9, 8 | "default_midi_device": 1, 9 | "default_midi_channel": 10, 10 | "name": "DRM Drum 1", 11 | "id": "drm-drum1", 12 | "fixed_note": 45 13 | }] -------------------------------------------------------------------------------- /lib/config/vermona_drm_drum_2.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "params": [], 3 | "type": "midi", 4 | "unique": true, 5 | 6 | "polyphonic": false, 7 | "value": 10, 8 | "default_midi_device": 1, 9 | "default_midi_channel": 10, 10 | "name": "DRM Drum 2", 11 | "id": "drm-drum2", 12 | "fixed_note": 50 13 | }] -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | branches: [ main ] 7 | 8 | jobs: 9 | Run-tests-on-Ubuntu: 10 | name: Run tests on Ubuntu 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: leafo/gh-actions-lua@v10 15 | with: 16 | luaVersion: "5.3" 17 | - run: ./test.sh 18 | -------------------------------------------------------------------------------- /lib/tests/helpers/globals.lua: -------------------------------------------------------------------------------- 1 | globals = {} 2 | 3 | 4 | globals.reset = function() 5 | 6 | midi_note_on_events = {} 7 | midi_note_off_events = {} 8 | midi_cc_events = {} 9 | song_edit_page_refresh_events = {} 10 | channel_edit_page_refresh_events = {} 11 | channel_edit_page_refresh_trig_locks_events = {} 12 | has_fired = nil 13 | m_grid = { 14 | get_pressed_keys = function() return {} end 15 | } 16 | end 17 | 18 | globals.reset() -------------------------------------------------------------------------------- /lib/tests/helpers/mocks/m_midi_mock.lua: -------------------------------------------------------------------------------- 1 | m_midi = {} 2 | m_midi.start = function() end 3 | 4 | function m_midi:note_on(note, velocity, channel, device) 5 | table.insert(midi_note_on_events, {note, velocity, channel, device}) 6 | end 7 | 8 | function m_midi:note_off(note, velocity, channel, device) 9 | table.insert(midi_note_off_events, {note, velocity, channel, device}) 10 | end 11 | 12 | function m_midi.cc(cc_msb, cc_lsb, value, channel, device) 13 | table.insert(midi_cc_events, {cc_msb, value, channel}) 14 | end -------------------------------------------------------------------------------- /lib/tests/helpers/mocks/norns_mock.lua: -------------------------------------------------------------------------------- 1 | _norns = {} 2 | _norns.clock = {} 3 | _norns.clock.run = function(func) 4 | print("Running function") 5 | func() 6 | end 7 | _norns.clock.sleep = function() end 8 | _norns.clock_cancel = function() end 9 | _norns.clock_schedule_sleep = function() end 10 | _norns.clock_schedule_sync = function() end 11 | 12 | _norns.clock_get_tempo = function() return 120 end 13 | 14 | clock = include("mosaic/lib/tests/test_artefacts/norns_test_artefact/lua/core/clock") 15 | 16 | clock.sleep = function() end 17 | clock.cancel = function() end 18 | -------------------------------------------------------------------------------- /lib/tests/helpers/mocks/params_mock.lua: -------------------------------------------------------------------------------- 1 | params = {} 2 | param_store = {} 3 | 4 | function params.reset() 5 | param_store = {} 6 | end 7 | 8 | function params:add(id, param) 9 | param_store[id] = param 10 | end 11 | 12 | function params:set(p, val) 13 | local param = param_store[p] 14 | if param == nil then 15 | param = {} 16 | end 17 | param.val = val 18 | param_store[p] = param 19 | end 20 | 21 | function params:get(p) 22 | local param = param_store[p] 23 | if param == nil then 24 | param = {} 25 | end 26 | 27 | return param.val 28 | end 29 | 30 | function params:lookup_param(id) 31 | return param_store[id] 32 | end -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Lua sources 2 | luac.out 3 | 4 | # luarocks build files 5 | *.src.rock 6 | *.zip 7 | *.tar.gz 8 | 9 | # Object files 10 | *.o 11 | *.os 12 | *.ko 13 | *.obj 14 | *.elf 15 | 16 | # Precompiled Headers 17 | *.gch 18 | *.pch 19 | 20 | # Libraries 21 | *.lib 22 | *.a 23 | *.la 24 | *.lo 25 | *.def 26 | *.exp 27 | 28 | # Shared objects (inc. Windows DLLs) 29 | *.dll 30 | *.so 31 | *.so.* 32 | *.dylib 33 | 34 | # Executables 35 | *.exe 36 | *.out 37 | *.app 38 | *.i*86 39 | *.x86_64 40 | *.hex 41 | 42 | lib/tests/test_artefacts 43 | lib/tests/test_artefacts/norns_test_artefact 44 | tests/luacov.report.out 45 | tests/luacov.stats.out 46 | sync.ffs_db 47 | lib/tests/test_artefacts/latest_release.json 48 | 49 | .DS_Store -------------------------------------------------------------------------------- /lib/pages/velocity_edit_page/velocity_edit_page_ui.lua: -------------------------------------------------------------------------------- 1 | local velocity_edit_page_ui = {} 2 | 3 | local grid_viewer = include("mosaic/lib/ui_components/grid_viewer") 4 | 5 | local grid_viewer = grid_viewer:new(0, 3) 6 | 7 | function velocity_edit_page_ui.change_page(subpage_name) 8 | end 9 | 10 | function velocity_edit_page_ui.register_ui_draws() 11 | draw:register_ui( 12 | "velocity_edit_page", 13 | function() 14 | grid_viewer:draw() 15 | end 16 | ) 17 | end 18 | 19 | function velocity_edit_page_ui.init() 20 | end 21 | 22 | function velocity_edit_page_ui.enc(n, d) 23 | if n == 2 then 24 | for i = 1, math.abs(d) do 25 | if d > 0 then 26 | grid_viewer:next_channel() 27 | else 28 | grid_viewer:prev_channel() 29 | end 30 | end 31 | end 32 | end 33 | 34 | return velocity_edit_page_ui 35 | -------------------------------------------------------------------------------- /lib/controls/paint_button.lua: -------------------------------------------------------------------------------- 1 | button = {} 2 | button.__index = button 3 | 4 | function button:new(x, y, states) 5 | local self = setmetatable({}, button) 6 | self.x = x 7 | self.y = y 8 | if (states) then 9 | self.states = states 10 | else 11 | self.states = {} 12 | self.states[1] = {"inactive", 3} 13 | self.states[2] = {"save", 15} 14 | end 15 | self.state = 1 16 | 17 | return self 18 | end 19 | 20 | function button:draw() 21 | grid_abstraction.led(self.x, self.y, self.states[self.state][2]) 22 | end 23 | 24 | function button:get_state() 25 | return self.value 26 | end 27 | 28 | function button:set_state(val) 29 | self.value = val 30 | end 31 | 32 | function button:press(x, y) 33 | if (self.x == x and self.y == y) then 34 | if self.state == 1 then 35 | self.state = self.state + 1 36 | elseif self.state == #states then 37 | self.state = 1 38 | end 39 | end 40 | end 41 | 42 | return button 43 | -------------------------------------------------------------------------------- /lib/tests/helpers/mocks/scheduler_mock.lua: -------------------------------------------------------------------------------- 1 | local scheduler = {} 2 | 3 | -- Track active coroutines 4 | scheduler.coroutines = {} 5 | 6 | function scheduler.start(co) 7 | if type(co) == "thread" then 8 | table.insert(scheduler.coroutines, co) 9 | end 10 | end 11 | 12 | -- Execute coroutine until completion 13 | function scheduler.update() 14 | for i, co in ipairs(scheduler.coroutines) do 15 | while coroutine.status(co) ~= 'dead' do 16 | local success = coroutine.resume(co) 17 | if not success then break end 18 | end 19 | end 20 | -- Clear completed coroutines 21 | scheduler.coroutines = {} 22 | end 23 | 24 | function scheduler.debounce(func) 25 | return function(...) 26 | local args = {...} 27 | local co = coroutine.create(function() 28 | func(table.unpack(args)) 29 | end) 30 | 31 | -- Start and immediately execute the coroutine 32 | scheduler.start(co) 33 | scheduler.update() 34 | end 35 | end 36 | 37 | return scheduler -------------------------------------------------------------------------------- /lib/pages/note_edit_page/note_edit_page_ui.lua: -------------------------------------------------------------------------------- 1 | local note_edit_page_ui = {} 2 | 3 | local pages = include("mosaic/lib/ui_components/pages") 4 | local page = include("mosaic/lib/ui_components/page") 5 | 6 | local grid_viewer = include("mosaic/lib/ui_components/grid_viewer") 7 | 8 | local grid_viewer = grid_viewer:new(0, 3) 9 | 10 | local grid_viewer_page = 11 | page:new( 12 | "", 13 | function() 14 | grid_viewer:draw() 15 | end 16 | ) 17 | 18 | function note_edit_page_ui.register_ui_draws() 19 | draw:register_ui( 20 | "note_edit_page", 21 | function() 22 | grid_viewer_page:draw() 23 | end 24 | ) 25 | end 26 | 27 | function note_edit_page_ui.init() 28 | end 29 | 30 | function note_edit_page_ui.enc(n, d) 31 | if n == 2 then 32 | for i = 1, math.abs(d) do 33 | if d > 0 then 34 | grid_viewer:next_channel() 35 | else 36 | grid_viewer:prev_channel() 37 | end 38 | end 39 | end 40 | end 41 | 42 | return note_edit_page_ui 43 | -------------------------------------------------------------------------------- /lib/tests/helpers/mocks/channel_edit_page_ui_mock.lua: -------------------------------------------------------------------------------- 1 | channel_edit_page_ui = {} 2 | channel_edit_page_ui.refresh_trig_locks = function() 3 | table.insert(channel_edit_page_refresh_trig_locks_events, true) 4 | end 5 | 6 | channel_edit_page_ui.align_global_and_local_shuffle_feel_values = function() end 7 | channel_edit_page_ui.align_global_and_local_swing_values = function() end 8 | channel_edit_page_ui.align_global_and_local_swing_shuffle_type_values = function() end 9 | channel_edit_page_ui.align_global_and_local_shuffle_basis_values = function() end 10 | channel_edit_page_ui.align_global_and_local_shuffle_amount_values = function() end 11 | channel_edit_page_ui.refresh_clock_mods = function() end 12 | channel_edit_page_ui.refresh_swing = function() end 13 | channel_edit_page_ui.refresh_swing_shuffle_type = function() end 14 | channel_edit_page_ui.refresh_shuffle_feel = function() end 15 | channel_edit_page_ui.refresh_shuffle_basis = function() end 16 | channel_edit_page_ui.refresh_shuffle_amount = function() end 17 | 18 | channel_edit_page_ui.set_note_dashboard_values = function() end -------------------------------------------------------------------------------- /lib/grid_abstraction.lua: -------------------------------------------------------------------------------- 1 | local grid_abstraction = {} 2 | 3 | grid_abstraction.state = {} 4 | grid_abstraction.screen_state = {} 5 | 6 | function grid_abstraction.init() 7 | grid_abstraction.state = {} 8 | grid_abstraction.screen_state = {} 9 | for x = 1, 16 do 10 | grid_abstraction.state[x] = {} 11 | grid_abstraction.screen_state[x] = {} 12 | for y = 1, 8 do 13 | grid_abstraction.state[x][y] = 0 14 | grid_abstraction.screen_state[x][y] = 0 15 | end 16 | end 17 | end 18 | 19 | function grid_abstraction.led(x, y, brightness) 20 | if x < 1 or y < 1 or x > 16 or y > 8 then 21 | return 22 | end 23 | g:led(x, y, brightness) 24 | grid_abstraction.state[x][y] = brightness 25 | end 26 | 27 | function grid_abstraction.seq(x, y, brightness) 28 | if x < 1 or y < 1 or x > 16 or y > 8 then 29 | return 30 | end 31 | grid_abstraction.screen_state[x][y] = brightness 32 | end 33 | 34 | function grid_abstraction.get_state() 35 | return grid_abstraction.state 36 | end 37 | 38 | function grid_abstraction.get_screen_state() 39 | return grid_abstraction.screen_state 40 | end 41 | 42 | return grid_abstraction 43 | -------------------------------------------------------------------------------- /lib/config/te_op_1.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "name": "OP-1", 3 | "map_params_automatically": false, 4 | "type": "midi", 5 | "polyphonic": true, 6 | "id": "op-1", 7 | "params": [ 8 | { 9 | "cc_msb": 0, 10 | "name": "Synth", 11 | "cc_max_value": 127, 12 | "short_descriptor_2": "LFO", 13 | "short_descriptor_1": "MIDI", 14 | "id": "midi_lfo_synth", 15 | "off_value": -1, 16 | "cc_min_value": -1 17 | }, 18 | { 19 | "cc_msb": 1, 20 | "name": "Envelope", 21 | "cc_max_value": 127, 22 | "short_descriptor_2": "LFOE", 23 | "short_descriptor_1": "MIDI", 24 | "id": "midi_lfo_envelope", 25 | "off_value": -1, 26 | "cc_min_value": -1 27 | }, 28 | { 29 | "cc_msb": 2, 30 | "name": "FX", 31 | "cc_max_value": 127, 32 | "short_descriptor_2": "LFOX", 33 | "short_descriptor_1": "MIDI", 34 | "id": "midi_lfo_fx", 35 | "off_value": -1, 36 | "cc_min_value": -1 37 | }, 38 | { 39 | "cc_msb": 3, 40 | "name": "Volume", 41 | "cc_max_value": 127, 42 | "short_descriptor_2": "LFOV", 43 | "short_descriptor_1": "MIDI", 44 | "id": "midi_lfo_volume", 45 | "off_value": -1, 46 | "cc_min_value": -1 47 | } 48 | ] 49 | }] -------------------------------------------------------------------------------- /lib/ui_components/tooltip.lua: -------------------------------------------------------------------------------- 1 | local tooltip = {} 2 | 3 | 4 | 5 | tooltip.text = false 6 | tooltip.metros = {} 7 | tooltip.error_flag = false 8 | 9 | function tooltip:draw() 10 | if tooltip.text then 11 | screen.move(0, 62) 12 | screen.font_size(8) 13 | screen.text(tooltip.text) 14 | end 15 | end 16 | 17 | -- Define a local remove function for this metro 18 | local function remove_tip() 19 | tooltip.text = false 20 | fn.dirty_screen(true) 21 | tooltip.error_flag = false 22 | end 23 | 24 | function tooltip:do_tip(text) 25 | -- Stop any existing metro and remove it from the table 26 | for i, m in ipairs(tooltip.metros) do 27 | m:stop() 28 | metro.free(i) 29 | table.remove(tooltip.metros, i) 30 | end 31 | 32 | tooltip.text = text 33 | fn.dirty_screen(true) 34 | 35 | -- Create a new metro 36 | local m = metro.init(remove_tip, 3, 1) 37 | 38 | -- Start the metro 39 | m:start() 40 | 41 | -- Add the metro to the metros table 42 | table.insert(tooltip.metros, m) 43 | end 44 | 45 | function tooltip:error(text) 46 | tooltip.error_flag = true 47 | tooltip:do_tip(text) 48 | end 49 | 50 | function tooltip:show(text) 51 | 52 | if tooltip.error_flag then 53 | return 54 | end 55 | 56 | tooltip:do_tip(text) 57 | 58 | end 59 | 60 | 61 | return tooltip 62 | -------------------------------------------------------------------------------- /lib/ui_components/page.lua: -------------------------------------------------------------------------------- 1 | local page = {} 2 | page.__index = page 3 | 4 | 5 | 6 | function page:new(name, func) 7 | local self = setmetatable({}, page) 8 | self.name = name 9 | self.draw_func = func 10 | self.sub_page_draw_func = function() 11 | end 12 | self.sub_name_func = function() 13 | return "" 14 | end 15 | self.sub_page_enabled = false 16 | return self 17 | end 18 | 19 | function page:get_name() 20 | return self.name 21 | end 22 | 23 | function page:set_name(name) 24 | self.name = name 25 | end 26 | 27 | function page:set_sub_name_func(func) 28 | self.sub_name_func = func 29 | end 30 | 31 | function page:enable_sub_page() 32 | self.sub_page_enabled = true 33 | end 34 | 35 | function page:disable_sub_page() 36 | self.sub_page_enabled = false 37 | end 38 | 39 | function page:set_sub_page_draw_func(func) 40 | self.sub_page_draw_func = func 41 | end 42 | 43 | function page:draw() 44 | screen.font_size(8) 45 | screen.level(10) 46 | screen.move(0, 9) 47 | screen.text(self.sub_name_func() .. self.name) 48 | if self.sub_page_enabled then 49 | self.sub_page_draw_func() 50 | else 51 | self.draw_func() 52 | end 53 | end 54 | 55 | function page:toggle_sub_page() 56 | self.sub_page_enabled = not self.sub_page_enabled 57 | fn.dirty_screen(true) 58 | end 59 | 60 | function page:is_sub_page_enabled() 61 | return self.sub_page_enabled 62 | end 63 | 64 | return page 65 | -------------------------------------------------------------------------------- /lib/ui_components/grid_viewer.lua: -------------------------------------------------------------------------------- 1 | local grid_viewer = {} 2 | grid_viewer.__index = grid_viewer 3 | 4 | local screen_view_sequencer = sequencer:new(4, "channel") 5 | 6 | function grid_viewer:new(x, y) 7 | local self = setmetatable({}, grid_viewer) 8 | self.x = x 9 | self.y = y 10 | self.selected_channel = 1 11 | 12 | return self 13 | end 14 | 15 | function grid_viewer:draw() 16 | screen_view_sequencer:draw(program.get_channel(program.get().selected_song_pattern, self.selected_channel), grid_abstraction.seq) 17 | 18 | local state = grid_abstraction.get_screen_state() 19 | 20 | for x = 1, 16 do 21 | for y = 1, 8 do 22 | screen.move(self.x - 3 + (x * 7), self.y - 5 + (y * 7)) 23 | screen.level(state[x][y]) 24 | screen.font_size(35) 25 | screen.text(".") 26 | end 27 | end 28 | screen.move(self.x, self.y + 6) 29 | screen.level(10) 30 | screen.font_size(8) 31 | screen.text("Channel " .. self.selected_channel .. " grid viewer") 32 | end 33 | 34 | function grid_viewer:next_channel() 35 | self.selected_channel = self.selected_channel + 1 36 | if self.selected_channel > 16 then 37 | self.selected_channel = 16 38 | end 39 | fn.dirty_screen(true) 40 | end 41 | 42 | function grid_viewer:prev_channel() 43 | self.selected_channel = self.selected_channel - 1 44 | if self.selected_channel < 1 then 45 | self.selected_channel = 1 46 | end 47 | fn.dirty_screen(true) 48 | end 49 | 50 | return grid_viewer 51 | -------------------------------------------------------------------------------- /lib/ui_components/pages.lua: -------------------------------------------------------------------------------- 1 | local pages = {} 2 | pages.__index = pages 3 | 4 | 5 | 6 | function pages:new() 7 | local self = setmetatable({}, pages) 8 | self.pages = {} 9 | self.selected_page = 0 10 | return self 11 | end 12 | 13 | function pages:draw() 14 | local x = 0 15 | 16 | for i = 1, fn.table_count(self.pages) do 17 | screen.move(x, 1) 18 | if self.selected_page == i then 19 | screen.level(10) 20 | else 21 | screen.level(1) 22 | end 23 | screen.text("_") 24 | x = x + 10 25 | end 26 | 27 | if (self.selected_page == nil) then 28 | if self.pages[1] then 29 | self.pages[1]:draw() 30 | end 31 | else 32 | if self.selected_page > 0 and self.pages[self.selected_page] then 33 | self.pages[self.selected_page]:draw() 34 | end 35 | end 36 | end 37 | 38 | function pages:add_page(page) 39 | table.insert(self.pages, page) 40 | end 41 | 42 | function pages:select_page(page) 43 | self.selected_page = page 44 | end 45 | 46 | function pages:get_selected_page() 47 | return self.selected_page 48 | end 49 | 50 | function pages:next_page() 51 | self.selected_page = self.selected_page + 1 52 | if self.selected_page > fn.table_count(self.pages) then 53 | self.selected_page = fn.table_count(self.pages) 54 | end 55 | end 56 | 57 | function pages:previous_page() 58 | self.selected_page = self.selected_page - 1 59 | if self.selected_page < 1 then 60 | self.selected_page = 1 61 | end 62 | end 63 | 64 | return pages 65 | -------------------------------------------------------------------------------- /lib/controls/button.lua: -------------------------------------------------------------------------------- 1 | button = {} 2 | button.__index = button 3 | 4 | function button:new(x, y, states) 5 | local self = setmetatable({}, button) 6 | self.x = x 7 | self.y = y 8 | self.blink_active = false 9 | if (states) then 10 | self.states = states 11 | else 12 | self.states = {} 13 | self.states[1] = {"off", 2} 14 | self.states[2] = {"on", 15} 15 | end 16 | self.state = 1 17 | self.bright_mod = 6 18 | self.pre_func = function() end 19 | self.post_func = function() end 20 | return self 21 | end 22 | 23 | function button:draw() 24 | if self.blink_active and program.get_blink_state() then 25 | grid_abstraction.led(self.x, self.y, self.states[self.state][2] - self.bright_mod) 26 | else 27 | grid_abstraction.led(self.x, self.y, self.states[self.state][2]) 28 | end 29 | end 30 | 31 | function button:get_state() 32 | return self.state 33 | end 34 | 35 | function button:set_state(val) 36 | self.state = val 37 | end 38 | 39 | function button:blink() 40 | self.blink_active = true 41 | end 42 | 43 | function button:no_blink() 44 | self.blink_active = false 45 | end 46 | 47 | function button:press(x, y) 48 | if (self.x == x and self.y == y) then 49 | if self.state == #self.states then 50 | self.state = 1 51 | else 52 | self.state = self.state + 1 53 | end 54 | end 55 | end 56 | 57 | function button:is_this(x, y) 58 | if (self.x == x and self.y == y) then 59 | return true 60 | end 61 | return false 62 | end 63 | 64 | return button 65 | -------------------------------------------------------------------------------- /lib/sinfonion_harmonic_sync.lua: -------------------------------------------------------------------------------- 1 | local math = require("math") 2 | local floor = math.floor 3 | 4 | local sinfonion = {} 5 | 6 | 7 | function sinfonion.set_root_note(root) 8 | m_midi.send_to_sinfonion(1, root) 9 | end 10 | 11 | function sinfonion.set_degree_nr(degree_nr) 12 | m_midi.send_to_sinfonion(2, degree_nr) 13 | end 14 | 15 | function sinfonion.set_mode_nr(mode_nr) 16 | m_midi.send_to_sinfonion(3, mode_nr) 17 | end 18 | 19 | function sinfonion.set_clock(clock) 20 | m_midi.send_to_sinfonion(5, clock) 21 | end 22 | 23 | -- Transposition takes a value of -64 to 63 24 | function sinfonion.set_transposition(trans) 25 | trans = math.max(-64, math.min(63, trans)) 26 | m_midi.send_to_sinfonion(4, trans + 64) 27 | end 28 | 29 | -- chaotic_detune takes a value of -1.0 to 1.0 30 | function sinfonion.set_chaotic_detune(detune) 31 | -- Ensure the input is within the expected range 32 | if detune > 1.0 then 33 | detune = 1.0 34 | elseif detune < -1.0 then 35 | detune = -1.0 36 | end 37 | 38 | -- First, scale and offset the floatValue to the range -64 to 63 39 | local adjusted_value = math.floor(detune * 64 + 0.5) 40 | 41 | -- Now, adjust the range to 0 to 127 by adding 64 42 | local midi_value = adjusted_value + 64 43 | 44 | m_midi.send_to_sinfonion(9, midi_value) 45 | end 46 | 47 | -- harmonic_shift takes a value of -11 to +11 48 | function sinfonion.set_harmonic_shift(shift) 49 | m_midi.send_to_sinfonion(10, shift + 11) 50 | end 51 | 52 | -- Beat 53 | function sinfonion.set_beat(beat) 54 | m_midi.send_to_sinfonion(6, beat) 55 | end 56 | 57 | -- Step 58 | function sinfonion.set_step(step) 59 | m_midi.send_to_sinfonion(7, step) 60 | end 61 | 62 | -- Reset 63 | function sinfonion.set_reset(reset_value) 64 | m_midi.send_to_sinfonion(8, reset_value) 65 | end 66 | 67 | return sinfonion 68 | -------------------------------------------------------------------------------- /lib/ui_components/save_confirm.lua: -------------------------------------------------------------------------------- 1 | local save_confirm = {} 2 | 3 | 4 | local save_funcs = {} 5 | local cancel_funcs = {} 6 | local default_confirm_message = "Press K3 to confirm" 7 | local default_ok_message = "OK" 8 | local default_cancel_message = "Action cancelled" 9 | local confirm_message = default_confirm_message 10 | local ok_message = default_ok_message 11 | local cancel_message = default_cancel_message 12 | 13 | function save_confirm.set_save(func) 14 | table.insert(save_funcs, func) 15 | tooltip:show(confirm_message) 16 | confirm_message = default_confirm_message 17 | end 18 | 19 | function save_confirm.set_cancel(func) 20 | table.insert(cancel_funcs, func) 21 | end 22 | 23 | function save_confirm.confirm() 24 | if #save_funcs > 0 then 25 | for _, func in ipairs(save_funcs) do 26 | func() 27 | end 28 | 29 | tooltip:show(ok_message) 30 | end 31 | save_funcs = {} 32 | cancel_funcs = {} 33 | ok_message = default_ok_message 34 | end 35 | 36 | function save_confirm.cancel() 37 | if #cancel_funcs > 0 then 38 | for _, func in ipairs(cancel_funcs) do 39 | func() 40 | end 41 | tooltip:show(cancel_message) 42 | end 43 | save_funcs = {} 44 | cancel_funcs = {} 45 | cancel_message = default_cancel_message 46 | end 47 | 48 | function save_confirm.set_confirm_message(message) 49 | confirm_message = message 50 | end 51 | 52 | function save_confirm.set_ok_message(message) 53 | ok_message = message 54 | end 55 | 56 | function save_confirm.set_cancel_message(message) 57 | cancel_message = message 58 | end 59 | 60 | function save_confirm.clear() 61 | save_funcs = {} 62 | cancel_funcs = {} 63 | confirm_message = default_confirm_message 64 | ok_message = default_ok_message 65 | cancel_message = default_cancel_message 66 | end 67 | 68 | return save_confirm 69 | -------------------------------------------------------------------------------- /lib/ui_components/control_scroll_selector.lua: -------------------------------------------------------------------------------- 1 | local control_scroll_selector = {} 2 | control_scroll_selector.__index = control_scroll_selector 3 | 4 | 5 | 6 | function control_scroll_selector:new(x, y, items) 7 | local self = setmetatable({}, control_scroll_selector) 8 | self.x = x 9 | self.y = y 10 | self.name = name 11 | self.items = items 12 | self.selected_item = 1 13 | return self 14 | end 15 | 16 | function control_scroll_selector:select(x) 17 | self.items[x]:select() 18 | end 19 | 20 | function control_scroll_selector:draw() 21 | for i = 1, #self.items do 22 | self.items[i]:draw() 23 | end 24 | end 25 | 26 | function control_scroll_selector:scroll_next() 27 | if self.selected_item < #self.items then 28 | self.selected_item = self.selected_item + 1 29 | end 30 | for i = 1, #self.items do 31 | self.items[i]:deselect() 32 | end 33 | self.items[self.selected_item]:select() 34 | fn.dirty_screen(true) 35 | end 36 | 37 | function control_scroll_selector:scroll_previous() 38 | if self.selected_item > 1 then 39 | self.selected_item = self.selected_item - 1 40 | end 41 | for i = 1, #self.items do 42 | self.items[i]:deselect() 43 | end 44 | self.items[self.selected_item]:select() 45 | fn.dirty_screen(true) 46 | end 47 | 48 | function control_scroll_selector:set_items(items) 49 | self.items = items 50 | fn.dirty_screen(true) 51 | end 52 | 53 | function control_scroll_selector:get_items() 54 | return self.items 55 | end 56 | 57 | function control_scroll_selector:get_selected_index() 58 | return self.selected_item 59 | end 60 | 61 | function control_scroll_selector:get_selected_item() 62 | return self.items[self.selected_item] 63 | end 64 | 65 | function control_scroll_selector:set_selected_item(item) 66 | self.items[self.selected_item]:select() 67 | fn.dirty_screen(true) 68 | end 69 | 70 | return control_scroll_selector 71 | -------------------------------------------------------------------------------- /lib/ui_components/list_selector.lua: -------------------------------------------------------------------------------- 1 | local list_selector = {} 2 | list_selector.__index = list_selector 3 | 4 | 5 | 6 | function list_selector:new(x, y, name, list) 7 | local self = setmetatable({}, list_selector) 8 | self.x = x 9 | self.y = y 10 | self.name = name 11 | self.list = list 12 | self.selected_value = 1 13 | self.selected = false 14 | 15 | return self 16 | end 17 | 18 | function list_selector:draw() 19 | if self.selected then 20 | screen.level(15) 21 | else 22 | screen.level(1) 23 | end 24 | screen.move(self.x, self.y) 25 | screen.font_size(8) 26 | screen.text(self.name) 27 | screen.move(self.x, self.y + 8) 28 | screen.text(self.list[self.selected_value].name) 29 | screen.font_size(8) 30 | end 31 | 32 | function list_selector:select() 33 | self.selected = true 34 | fn.dirty_screen(true) 35 | end 36 | 37 | function list_selector:deselect() 38 | self.selected = false 39 | fn.dirty_screen(true) 40 | end 41 | 42 | function list_selector:is_selected() 43 | return self.selected 44 | end 45 | 46 | function list_selector:increment() 47 | self.selected_value = self.selected_value + 1 48 | if self.selected_value > #self.list then 49 | self.selected_value = #self.list 50 | end 51 | fn.dirty_screen(true) 52 | end 53 | 54 | function list_selector:decrement() 55 | self.selected_value = self.selected_value - 1 56 | if self.selected_value < 1 then 57 | self.selected_value = 1 58 | end 59 | fn.dirty_screen(true) 60 | end 61 | 62 | function list_selector:set_selected_value(selected_value) 63 | self.selected_value = selected_value 64 | fn.dirty_screen(true) 65 | end 66 | 67 | function list_selector:get_selected() 68 | return self.list[self.selected_value] 69 | end 70 | 71 | function list_selector:set_name() 72 | self.name = name 73 | fn.dirty_screen(true) 74 | end 75 | 76 | function list_selector:set_list(list) 77 | self.list = list 78 | fn.dirty_screen(true) 79 | end 80 | 81 | return list_selector 82 | -------------------------------------------------------------------------------- /lib/ui_components/value_selector.lua: -------------------------------------------------------------------------------- 1 | local value_selector = {} 2 | value_selector.__index = value_selector 3 | 4 | 5 | 6 | function value_selector:new(x, y, name, min, max) 7 | local self = setmetatable({}, value_selector) 8 | self.x = x 9 | self.y = y 10 | self.name = name 11 | self.min = min 12 | self.max = max 13 | self.value = 0 14 | self.selected = false 15 | self.view_transform_func = function(value) return value end 16 | 17 | return self 18 | end 19 | 20 | function value_selector:draw() 21 | if self.selected then 22 | screen.level(15) 23 | else 24 | screen.level(1) 25 | end 26 | screen.move(self.x, self.y) 27 | screen.font_size(8) 28 | screen.text(self.name) 29 | screen.move(self.x, self.y + 8) 30 | if (self.value) then 31 | screen.text(self.view_transform_func(self.value)) 32 | else 33 | screen.text("0") 34 | end 35 | end 36 | 37 | function value_selector:select() 38 | self.selected = true 39 | fn.dirty_screen(true) 40 | end 41 | 42 | function value_selector:deselect() 43 | self.selected = false 44 | fn.dirty_screen(true) 45 | end 46 | 47 | function value_selector:is_selected() 48 | return self.selected 49 | end 50 | 51 | function value_selector:increment() 52 | self.value = self.value + 1 53 | if self.value > self.max then 54 | self.value = self.max 55 | end 56 | fn.dirty_screen(true) 57 | end 58 | 59 | function value_selector:decrement() 60 | self.value = self.value - 1 61 | if self.value < self.min then 62 | self.value = self.min 63 | end 64 | fn.dirty_screen(true) 65 | end 66 | 67 | function value_selector:get_value() 68 | return self.value 69 | end 70 | 71 | function value_selector:set_name() 72 | self.name = name 73 | fn.dirty_screen(true) 74 | end 75 | 76 | function value_selector:set_value(v) 77 | self.value = v 78 | fn.dirty_screen(true) 79 | end 80 | 81 | function value_selector:set_view_transform_func(func) 82 | self.view_transform_func = func 83 | end 84 | 85 | return value_selector 86 | -------------------------------------------------------------------------------- /lib/devices/norns_param_state_handler.lua: -------------------------------------------------------------------------------- 1 | local norns_param_state_handler = {} 2 | 3 | local original_param_state = { 4 | {{}, {}, {}, {}, {}, {}, {}, {}, {}, {}}, 5 | {{}, {}, {}, {}, {}, {}, {}, {}, {}, {}}, 6 | {{}, {}, {}, {}, {}, {}, {}, {}, {}, {}}, 7 | {{}, {}, {}, {}, {}, {}, {}, {}, {}, {}}, 8 | {{}, {}, {}, {}, {}, {}, {}, {}, {}, {}}, 9 | {{}, {}, {}, {}, {}, {}, {}, {}, {}, {}}, 10 | {{}, {}, {}, {}, {}, {}, {}, {}, {}, {}}, 11 | {{}, {}, {}, {}, {}, {}, {}, {}, {}, {}}, 12 | {{}, {}, {}, {}, {}, {}, {}, {}, {}, {}}, 13 | {{}, {}, {}, {}, {}, {}, {}, {}, {}, {}}, 14 | {{}, {}, {}, {}, {}, {}, {}, {}, {}, {}}, 15 | {{}, {}, {}, {}, {}, {}, {}, {}, {}, {}}, 16 | {{}, {}, {}, {}, {}, {}, {}, {}, {}, {}}, 17 | {{}, {}, {}, {}, {}, {}, {}, {}, {}, {}}, 18 | {{}, {}, {}, {}, {}, {}, {}, {}, {}, {}}, 19 | {{}, {}, {}, {}, {}, {}, {}, {}, {}, {}} 20 | } 21 | 22 | function norns_param_state_handler.flush_norns_original_param_trig_lock_store() 23 | for c = 1, 16 do 24 | for i = 1, 10 do 25 | if original_param_state[c] and original_param_state[c][i] and original_param_state[c][i].param_id then 26 | params:set(original_param_state[c][i].param_id, original_param_state[c][i].value) 27 | original_param_state[c][i] = {} 28 | end 29 | end 30 | end 31 | end 32 | 33 | function norns_param_state_handler.set_original_param_state(c, i, value, param_id) 34 | if not original_param_state[c] then 35 | original_param_state[c] = {} 36 | end 37 | original_param_state[c][i] = { 38 | value = value, 39 | param_id = param_id 40 | } 41 | end 42 | 43 | function norns_param_state_handler.get_original_param_state(c, i) 44 | if not original_param_state[c] or not original_param_state[c][i] then 45 | return nil 46 | end 47 | return original_param_state[c][i] 48 | end 49 | 50 | function norns_param_state_handler.clear_original_param_state(c, i) 51 | if original_param_state[c] then 52 | original_param_state[c][i] = {} 53 | end 54 | end 55 | 56 | return norns_param_state_handler -------------------------------------------------------------------------------- /lib/draw.lua: -------------------------------------------------------------------------------- 1 | local draw = {} 2 | local table_insert = table.insert 3 | local ipairs = ipairs 4 | 5 | -- Create a table for the handlers 6 | draw.grid_handlers = {} 7 | draw.ui_handlers = {} 8 | 9 | -- Register a function with a page 10 | function draw:register_grid(page, func) 11 | -- If no functions have been registered for this page yet, create a new list 12 | if self.grid_handlers[page] == nil then 13 | self.grid_handlers[page] = {} 14 | end 15 | 16 | -- Add the function to the list of handlers for this page 17 | table_insert(self.grid_handlers[page], func) 18 | end 19 | 20 | -- Call all functions registered with a page 21 | function draw:handle_grid(page) 22 | 23 | local found_page = fn.find_key(pages.pages, page) 24 | 25 | -- Call all menu press handlers 26 | for _, func in ipairs(self.grid_handlers["menu"]) do 27 | func(x, y) 28 | end 29 | 30 | -- If no functions have been registered for this page, do nothing 31 | if self.grid_handlers[found_page] == nil then 32 | return 33 | end 34 | 35 | -- Otherwise, call all functions registered for this page 36 | for _, func in ipairs(self.grid_handlers[found_page]) do 37 | func() 38 | end 39 | end 40 | 41 | -- Register a function with a page 42 | function draw:register_ui(page, func) 43 | -- If no functions have been registered for this page yet, create a new list 44 | if self.ui_handlers[page] == nil then 45 | self.ui_handlers[page] = {} 46 | end 47 | 48 | -- Add the function to the list of ui_handlers for this page 49 | table_insert(self.ui_handlers[page], func) 50 | end 51 | 52 | -- Call all functions registered with a page 53 | function draw:handle_ui(page) 54 | 55 | local found_page = fn.find_key(pages.pages, page) 56 | 57 | -- Call all menu press handlers 58 | for _, func in ipairs(self.ui_handlers["tooltip"]) do 59 | func(x, y) 60 | end 61 | 62 | -- If no functions have been registered for this page, do nothing 63 | if self.ui_handlers[found_page] == nil then 64 | return 65 | end 66 | 67 | -- Otherwise, call all functions registered for this page 68 | for _, func in ipairs(self.ui_handlers[found_page]) do 69 | func() 70 | end 71 | end 72 | 73 | return draw 74 | -------------------------------------------------------------------------------- /lib/controls/fade_button.lua: -------------------------------------------------------------------------------- 1 | local fade_button = {} 2 | fade_button.__index = fade_button 3 | 4 | function fade_button:new(x, y, min, max, button_type) 5 | local self = setmetatable({}, fade_button) 6 | self.x = x 7 | self.y = y 8 | self.min = min 9 | self.max = max 10 | self.button_type = button_type -- "up", "down", or "center" 11 | self.value = min 12 | self.step_size = 1 13 | return self 14 | end 15 | 16 | function fade_button:draw() 17 | local brightness = 3 18 | 19 | if self.button_type == "up" or self.button_type == "down" then 20 | -- Calculate brightness based on value position 21 | local range = self.max - self.min 22 | local position = self.value - self.min 23 | 24 | if self.button_type == "down" then -- Swapped this to match the direction 25 | position = range - position 26 | end 27 | 28 | brightness = math.max(4, 15 - position * 2) -- Fade from 15 to 4 29 | elseif self.button_type == "center" then 30 | local mid_point = math.floor((self.max - self.min) / 2) + self.min 31 | brightness = self.value == mid_point and 15 or 8 32 | end 33 | 34 | grid_abstraction.led(self.x, self.y, brightness) 35 | end 36 | 37 | function fade_button:set_value(val) 38 | self.value = math.max(self.min, math.min(val, self.max)) 39 | end 40 | 41 | function fade_button:get_value() 42 | return self.value 43 | end 44 | 45 | function fade_button:press(x, y) 46 | if not self:is_this(x, y) then 47 | return false 48 | end 49 | 50 | if self.button_type == "down" and self.value < self.max then 51 | self.value = self.value + self.step_size 52 | return self.value 53 | elseif self.button_type == "up" and self.value > self.min then 54 | self.value = self.value - self.step_size 55 | return self.value 56 | elseif self.button_type == "center" then 57 | self.value = math.floor((self.max - self.min) / 2) + self.min 58 | return self.value 59 | end 60 | 61 | return false 62 | end 63 | 64 | function fade_button:is_this(x, y) 65 | return (self.x == x and self.y == y) 66 | end 67 | 68 | return fade_button -------------------------------------------------------------------------------- /lib/tests/lib/integration_tests/channel_tests.lua: -------------------------------------------------------------------------------- 1 | step = include("mosaic/lib/step") 2 | pattern = include("mosaic/lib/pattern") 3 | 4 | local m_clock = include("mosaic/lib/clock/m_clock") 5 | local quantiser = include("mosaic/lib/quantiser") 6 | 7 | -- Mocks 8 | include("mosaic/lib/tests/helpers/mocks/sinfonion_mock") 9 | include("mosaic/lib/tests/helpers/mocks/params_mock") 10 | include("mosaic/lib/tests/helpers/mocks/m_midi_mock") 11 | include("mosaic/lib/tests/helpers/mocks/channel_edit_page_ui_mock") 12 | include("mosaic/lib/tests/helpers/mocks/device_map_mock") 13 | include("mosaic/lib/tests/helpers/mocks/norns_mock") 14 | include("mosaic/lib/tests/helpers/mocks/channel_sequence_page_mock") 15 | include("mosaic/lib/tests/helpers/mocks/channel_edit_page_mock") 16 | 17 | local function setup() 18 | program.init() 19 | globals.reset() 20 | params.reset() 21 | end 22 | 23 | local function clock_setup() 24 | m_clock.init() 25 | m_clock:start() 26 | end 27 | 28 | local function progress_clock_by_beats(b) 29 | for i = 1, (24 * b) do 30 | m_clock.get_clock_lattice():pulse() 31 | end 32 | end 33 | 34 | local function progress_clock_by_pulses(p) 35 | for i = 1, p do 36 | m_clock.get_clock_lattice():pulse() 37 | end 38 | end 39 | 40 | function test_channel_17_doesnt_fire_notes() 41 | 42 | local test_pattern 43 | 44 | setup() 45 | local song_pattern = 3 46 | program.set_selected_song_pattern(3) 47 | test_pattern = program.initialise_default_pattern() 48 | test_pattern2 = program.initialise_default_pattern() 49 | 50 | local steps = 6 51 | 52 | test_pattern.note_values[steps] = 0 53 | test_pattern.lengths[steps] = 1 54 | test_pattern.trig_values[steps] = 1 55 | test_pattern.velocity_values[steps] = 20 56 | 57 | program.get_song_pattern(song_pattern).patterns[1] = test_pattern 58 | 59 | fn.add_to_set(program.get_song_pattern(song_pattern).channels[17].selected_patterns, 1) 60 | 61 | pattern.update_working_patterns() 62 | 63 | -- Reset and set up the clock and MIDI event tracking 64 | clock_setup() 65 | 66 | -- Progress the clock according to the current steps being tested 67 | progress_clock_by_beats(steps) 68 | 69 | local note_on_event = table.remove(midi_note_on_events, 1) 70 | 71 | -- Check there are no note on events 72 | luaunit.assertNil(note_on_event) 73 | 74 | end 75 | -------------------------------------------------------------------------------- /lib/config/dt_m_sample.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "params": [ 3 | { 4 | "cc_max_value": 127, 5 | "off_value": -1, 6 | "short_descriptor_2": "FLDR", 7 | "name": "Folder", 8 | "short_descriptor_1": "SMPL", 9 | "cc_min_value": -1, 10 | "cc_msb": 13, 11 | "id": "folder" 12 | }, 13 | { 14 | "cc_max_value": 127, 15 | "off_value": -1, 16 | "short_descriptor_2": "TIME", 17 | "name": "Attack Time", 18 | "short_descriptor_1": "ATCK", 19 | "cc_min_value": -1, 20 | "cc_msb": 21, 21 | "id": "attack_time" 22 | }, 23 | { 24 | "cc_max_value": 127, 25 | "off_value": -1, 26 | "short_descriptor_2": "TIME", 27 | "name": "Decay Time", 28 | "short_descriptor_1": "DCAY", 29 | "cc_min_value": -1, 30 | "cc_msb": 22, 31 | "id": "decay_time" 32 | }, 33 | { 34 | "cc_max_value": 127, 35 | "off_value": -1, 36 | "short_descriptor_2": "TIME", 37 | "name": "Sustain Time", 38 | "short_descriptor_1": "SUST", 39 | "cc_min_value": -1, 40 | "cc_msb": 23, 41 | "id": "sustain_time" 42 | }, 43 | { 44 | "cc_max_value": 127, 45 | "off_value": -1, 46 | "short_descriptor_2": "TIME", 47 | "name": "Release Time", 48 | "short_descriptor_1": "RELE", 49 | "cc_min_value": -1, 50 | "cc_msb": 24, 51 | "id": "release_time" 52 | }, 53 | { 54 | "cc_max_value": 127, 55 | "off_value": -1, 56 | "short_descriptor_2": "SPOS", 57 | "name": "Transpose", 58 | "short_descriptor_1": "TRAN", 59 | "cc_min_value": -1, 60 | "cc_msb": 11, 61 | "id": "transpose" 62 | }, 63 | { 64 | "cc_max_value": 127, 65 | "off_value": -1, 66 | "short_descriptor_2": "GAIN", 67 | "name": "Gain", 68 | "short_descriptor_1": "AMP", 69 | "cc_min_value": -1, 70 | "cc_msb": 2, 71 | "id": "gain" 72 | }, 73 | { 74 | "cc_max_value": 127, 75 | "off_value": -1, 76 | "short_descriptor_2": "PAN", 77 | "name": "Pan", 78 | "short_descriptor_1": "AMP", 79 | "cc_min_value": -1, 80 | "cc_msb": 17, 81 | "id": "pan" 82 | } 83 | ], 84 | "name": "DT M Sample", 85 | "type": "midi", 86 | "value": 6, 87 | "polyphonic": true, 88 | "map_params_automatically": false, 89 | "id": "dt-multi-sample" 90 | }] -------------------------------------------------------------------------------- /lib/pages/pages.lua: -------------------------------------------------------------------------------- 1 | 2 | local pages = {} 3 | 4 | local PAGE_DEFINITIONS = { 5 | { 6 | id = "channel_edit_page", 7 | number = 2, 8 | name = "Channel Editor", 9 | grid_button = 3, 10 | controller = "channel_edit_page" 11 | }, 12 | { 13 | id = "scale_edit_page", 14 | number = 3, 15 | name = "Scale Editor", 16 | grid_button = 4, 17 | controller = "scale_edit_page" 18 | }, 19 | { 20 | id = "trigger_edit_page", 21 | number = 4, 22 | name = "Pattern Trig Editor", 23 | grid_button = 5, 24 | controller = "trigger_edit_page" 25 | }, 26 | { 27 | id = "note_edit_page", 28 | number = 5, 29 | name = "Pattern Note Editor", 30 | grid_button = 5, 31 | controller = "note_edit_page" 32 | }, 33 | { 34 | id = "velocity_edit_page", 35 | number = 6, 36 | name = "Pattern Velocity Editor", 37 | grid_button = 5, 38 | controller = "velocity_edit_page" 39 | }, 40 | { 41 | id = "song_edit_page", 42 | number = 7, 43 | name = "Song Editor", 44 | grid_button = 6, 45 | controller = "song_edit_page" 46 | } 47 | } 48 | 49 | -- Initialize pages enum 50 | pages.pages = {} 51 | for _, page in ipairs(PAGE_DEFINITIONS) do 52 | pages.pages[page.id] = page.number 53 | end 54 | 55 | -- Initialize page numbers to IDs mapping 56 | pages.page_numbers_to_ids = {} 57 | for _, page in ipairs(PAGE_DEFINITIONS) do 58 | pages.page_numbers_to_ids[page.number] = page.id 59 | end 60 | 61 | -- Initialize page names 62 | pages.page_names = {"Memory"} -- First page is special case 63 | for _, page in ipairs(PAGE_DEFINITIONS) do 64 | pages.page_names[page.number] = page.name 65 | end 66 | 67 | -- Initialize grid menu mappings 68 | pages.grid_menu_to_page_mappings = {} 69 | pages.pages_to_grid_menu_button_mappings = {} 70 | for _, page in ipairs(PAGE_DEFINITIONS) do 71 | pages.grid_menu_to_page_mappings[page.grid_button] = page.number 72 | pages.pages_to_grid_menu_button_mappings[page.id] = page.grid_button 73 | end 74 | 75 | function pages.initialise_page_controller_mappings() 76 | 77 | -- Initialize controller mappings 78 | pages.grid_menu_buttons_to_controller_mappings = { 79 | [1] = trigger_edit_page, -- memory placeholder 80 | [2] = trigger_edit_page -- blank 81 | } 82 | for _, page in ipairs(PAGE_DEFINITIONS) do 83 | pages.grid_menu_buttons_to_controller_mappings[page.grid_button] = _G[page.controller] 84 | end 85 | 86 | -- Initialize page to controller mappings 87 | pages.page_to_controller_mappings = {} 88 | for _, page in ipairs(PAGE_DEFINITIONS) do 89 | pages.page_to_controller_mappings[page.number] = _G[page.controller] 90 | end 91 | end 92 | 93 | return pages -------------------------------------------------------------------------------- /lib/pages/trigger_edit_page/trigger_edit_page_ui.lua: -------------------------------------------------------------------------------- 1 | local trigger_edit_page_ui = {} 2 | 3 | 4 | local pages = include("mosaic/lib/ui_components/pages") 5 | local page = include("mosaic/lib/ui_components/page") 6 | local grid_viewer = include("mosaic/lib/ui_components/grid_viewer") 7 | local list_selector = include("mosaic/lib/ui_components/list_selector") 8 | 9 | local pages = pages:new() 10 | local grid_viewer = grid_viewer:new(0, 3) 11 | local tresillo_mult = 12 | list_selector:new( 13 | 0, 14 | 29, 15 | "Tresillo mult", 16 | { 17 | {id = 1, value = 8, name = "x8"}, 18 | {id = 2, value = 16, name = "x16"}, 19 | {id = 3, value = 24, name = "x24"}, 20 | {id = 4, value = 32, name = "x32"}, 21 | {id = 5, value = 40, name = "x40"}, 22 | {id = 6, value = 48, name = "x48"}, 23 | {id = 7, value = 56, name = "x56"}, 24 | {id = 8, value = 64, name = "x64"} 25 | } 26 | ) 27 | 28 | 29 | local grid_viewer_page = 30 | page:new( 31 | "", 32 | function() 33 | grid_viewer:draw() 34 | end 35 | ) 36 | 37 | local trig_edit_options_page = 38 | page:new( 39 | "Trig editor options", 40 | function() 41 | tresillo_mult:draw() 42 | end 43 | ) 44 | 45 | function trigger_edit_page_ui.register_ui_draws() 46 | draw:register_ui( 47 | "trigger_edit_page", 48 | function() 49 | pages:draw() 50 | end 51 | ) 52 | end 53 | 54 | function trigger_edit_page_ui.init() 55 | pages:add_page(grid_viewer_page) 56 | pages:add_page(trig_edit_options_page) 57 | pages:select_page(1) 58 | tresillo_mult:select() 59 | trigger_edit_page_ui.refresh_tresillo() 60 | end 61 | 62 | function trigger_edit_page_ui.enc(n, d) 63 | if n == 2 then 64 | for i = 1, math.abs(d) do 65 | if d > 0 then 66 | grid_viewer:next_channel() 67 | else 68 | grid_viewer:prev_channel() 69 | end 70 | end 71 | end 72 | 73 | if n == 1 then 74 | for i = 1, math.abs(d) do 75 | if d > 0 then 76 | pages:next_page() 77 | fn.dirty_screen(true) 78 | else 79 | pages:previous_page() 80 | fn.dirty_screen(true) 81 | end 82 | end 83 | end 84 | 85 | if n == 3 then 86 | for i = 1, math.abs(d) do 87 | if d > 0 then 88 | if pages:get_selected_page() == 2 then 89 | tresillo_mult:increment() 90 | trigger_edit_page_ui.update_tresillo() 91 | end 92 | else 93 | if pages:get_selected_page() == 2 then 94 | tresillo_mult:decrement() 95 | trigger_edit_page_ui.update_tresillo() 96 | end 97 | end 98 | end 99 | end 100 | end 101 | 102 | function trigger_edit_page_ui.update_tresillo() 103 | params:set("tresillo_amount", tresillo_mult:get_selected().id) 104 | end 105 | 106 | function trigger_edit_page_ui.refresh_tresillo() 107 | tresillo_mult:set_selected_value(params:get("tresillo_amount")) 108 | end 109 | 110 | function trigger_edit_page_ui:refresh() 111 | trigger_edit_page_ui.refresh_tresillo() 112 | end 113 | 114 | return trigger_edit_page_ui 115 | -------------------------------------------------------------------------------- /lib/scheduler.lua: -------------------------------------------------------------------------------- 1 | local scheduler = { 2 | coroutines = {}, 3 | active_count = 0, 4 | next_id = 1 5 | } 6 | 7 | -- Pre-allocate a reusable removal set 8 | local INITIAL_REMOVAL_SET_SIZE = 32 9 | local removal_set = {} 10 | for i = 1, INITIAL_REMOVAL_SET_SIZE do 11 | removal_set[i] = false 12 | end 13 | 14 | function scheduler.start(co) 15 | if type(co) ~= "thread" then 16 | return nil 17 | end 18 | 19 | local id = scheduler.next_id 20 | scheduler.coroutines[id] = { 21 | co = co, 22 | created_at = os.time(), 23 | active = true 24 | } 25 | scheduler.active_count = scheduler.active_count + 1 26 | scheduler.next_id = id + 1 27 | return id 28 | end 29 | 30 | -- Reuse this table to avoid allocations 31 | local sorted_ids = {} 32 | 33 | function scheduler.update() 34 | -- Get sorted list of active coroutine IDs 35 | local idx = 1 36 | for id, co_data in pairs(scheduler.coroutines) do 37 | if co_data.active then 38 | sorted_ids[idx] = id 39 | idx = idx + 1 40 | end 41 | end 42 | -- Clear any remaining old entries 43 | for i = idx, #sorted_ids do 44 | sorted_ids[i] = nil 45 | end 46 | table.sort(sorted_ids) 47 | 48 | -- Process coroutines in order 49 | local removal_count = 0 50 | for i, id in ipairs(sorted_ids) do 51 | local co_data = scheduler.coroutines[id] 52 | 53 | if coroutine.status(co_data.co) ~= 'dead' then 54 | local success, error = coroutine.resume(co_data.co) 55 | if not success then 56 | print("Coroutine error: " .. tostring(error)) 57 | removal_count = removal_count + 1 58 | removal_set[removal_count] = id 59 | elseif coroutine.status(co_data.co) == 'dead' then 60 | removal_count = removal_count + 1 61 | removal_set[removal_count] = id 62 | end 63 | else 64 | removal_count = removal_count + 1 65 | removal_set[removal_count] = id 66 | end 67 | end 68 | 69 | -- Process removals 70 | for i = 1, removal_count do 71 | local id = removal_set[i] 72 | scheduler.coroutines[id].active = false 73 | removal_set[i] = false -- Reset for reuse 74 | end 75 | scheduler.active_count = scheduler.active_count - removal_count 76 | 77 | -- Periodic cleanup of inactive coroutines 78 | if scheduler.active_count < scheduler.next_id / 2 then 79 | local new_coroutines = {} 80 | for id, co_data in pairs(scheduler.coroutines) do 81 | if co_data.active then 82 | new_coroutines[id] = co_data 83 | end 84 | end 85 | scheduler.coroutines = new_coroutines 86 | end 87 | end 88 | 89 | function scheduler.debounce(func) 90 | local current_co = nil 91 | 92 | return function(...) 93 | -- Deactivate existing coroutine if it exists 94 | if current_co and scheduler.coroutines[current_co] then 95 | scheduler.coroutines[current_co].active = false 96 | scheduler.active_count = scheduler.active_count - 1 97 | end 98 | 99 | local args = {...} 100 | local co = coroutine.create(function() 101 | func(table.unpack(args)) 102 | end) 103 | 104 | current_co = scheduler.start(co) 105 | end 106 | end 107 | 108 | return scheduler -------------------------------------------------------------------------------- /lib/controls/vertical_fader.lua: -------------------------------------------------------------------------------- 1 | vertical_fader = {} 2 | vertical_fader.__index = vertical_fader 3 | 4 | 5 | function vertical_fader:new(x, y, size) 6 | local self = setmetatable({}, vertical_fader) 7 | self.x = x 8 | self.y = y 9 | self.size = size 10 | self.value = 0 11 | self.vertical_offset = 0 12 | self.horizontal_offset = 0 13 | self.led_brightness = 3 14 | return self 15 | end 16 | 17 | function vertical_fader:draw() 18 | local x = self.x - self.horizontal_offset 19 | 20 | if (x < 1 or x > 16) then 21 | return 22 | end 23 | 24 | local bright_mod = 0 25 | local shared_bright_mod = -1 26 | 27 | if program.get_blink_state() then 28 | shared_bright_mod = 1 29 | else 30 | shared_bright_mod = -1 31 | end 32 | 33 | for i = self.y, 7 do 34 | bright_mod = 0 35 | 36 | if x == program.get().selected_pattern then 37 | if i == 1 then 38 | bright_mod = shared_bright_mod 39 | end 40 | end 41 | 42 | -- Calculate reference line position based on whether we're in positive or negative notes 43 | local reference_line 44 | if self.vertical_offset <= 7 then 45 | reference_line = math.abs(7 - self.vertical_offset) 46 | else -- this is imperfect as it's hard coded for our two usecases, but it works for now 47 | reference_line = self.vertical_offset % 7 == 0 and 7 or nil 48 | end 49 | 50 | if (i == reference_line) then 51 | grid_abstraction.led(x, i, 3 + bright_mod) -- mark the bottom of each page 52 | elseif ((i == 7) and (self.vertical_offset == 7)) then 53 | grid_abstraction.led(x, i, 4 + bright_mod) -- mark the zero line stronger 54 | elseif (self.size - i - self.vertical_offset + 1 > 0) then 55 | grid_abstraction.led(x, i, self.led_brightness + bright_mod) 56 | end 57 | end 58 | 59 | local active_led = self.y + self.value - 1 - self.vertical_offset 60 | if (self.value > 0 and active_led < 8) then 61 | if self.x == program.get().selected_pattern then 62 | if self.y == active_led then 63 | bright_mod = shared_bright_mod 64 | else 65 | bright_mod = 0 66 | end 67 | end 68 | grid_abstraction.led(x, active_led, 12 + bright_mod) 69 | end 70 | end 71 | 72 | function vertical_fader:press(x, y) 73 | if y >= self.y and y <= 7 and x == self.x - self.horizontal_offset then 74 | self.value = y + self.vertical_offset 75 | end 76 | end 77 | 78 | function vertical_fader:set_vertical_offset(o) 79 | self.vertical_offset = o 80 | end 81 | 82 | function vertical_fader:get_vertical_offset() 83 | return self.vertical_offset 84 | end 85 | 86 | function vertical_fader:set_horizontal_offset(o) 87 | self.horizontal_offset = o 88 | end 89 | 90 | function vertical_fader:get_horizontal_offset() 91 | return self.horizontal_offset 92 | end 93 | 94 | function vertical_fader:get_value() 95 | return self.value 96 | end 97 | 98 | function vertical_fader:set_value(val) 99 | self.value = val 100 | end 101 | 102 | function vertical_fader:set_dark() 103 | self.led_brightness = 1 104 | end 105 | 106 | function vertical_fader:set_light() 107 | self.led_brightness = 3 108 | end 109 | 110 | function vertical_fader:is_this(x, y) 111 | if (self.x == x + self.horizontal_offset and y <= 7) then 112 | return true 113 | end 114 | return false 115 | end 116 | 117 | return vertical_fader 118 | -------------------------------------------------------------------------------- /lib/ui_components/vertical_scroll_selector.lua: -------------------------------------------------------------------------------- 1 | local vertical_scroll_selector = {} 2 | vertical_scroll_selector.__index = vertical_scroll_selector 3 | 4 | 5 | 6 | function vertical_scroll_selector:new(x, y, name, items) 7 | local self = setmetatable({}, vertical_scroll_selector) 8 | self.name = name 9 | self.x = x 10 | self.y = y 11 | 12 | self.items = items 13 | self.meta_items = nil 14 | self.selected_item = 1 15 | 16 | self.selected = false 17 | 18 | return self 19 | end 20 | 21 | function vertical_scroll_selector:get_selected_index() 22 | return self.selected_item 23 | end 24 | 25 | function vertical_scroll_selector:get_selected_item() 26 | return self.items[self.selected_item] 27 | end 28 | 29 | function vertical_scroll_selector:set_selected_item(selected_item) 30 | self.selected_item = selected_item 31 | end 32 | 33 | function vertical_scroll_selector:set_items(items) 34 | self.items = items 35 | end 36 | 37 | function vertical_scroll_selector:get_items() 38 | return self.items 39 | end 40 | 41 | function vertical_scroll_selector:get_meta_item() 42 | return self.meta_items 43 | end 44 | 45 | function vertical_scroll_selector:set_meta_item(items) 46 | self.meta_items = items 47 | end 48 | 49 | function vertical_scroll_selector:draw() 50 | if not self.items then 51 | return 52 | end 53 | 54 | screen.move(self.x, self.y) 55 | 56 | if self.selected_item and self.items[self.selected_item - 1] then 57 | screen.level(1) 58 | if self.items[self.selected_item - 1].name then 59 | screen.text(self.items[self.selected_item - 1].name, 12) 60 | else 61 | screen.text(self.items[self.selected_item - 1], 12) 62 | end 63 | end 64 | 65 | screen.move(self.x + 5, self.y + 10) 66 | 67 | if self.items[self.selected_item] then 68 | if self.selected then 69 | screen.level(15) 70 | else 71 | screen.level(5) 72 | end 73 | if self.items[self.selected_item].name then 74 | screen.text(self.items[self.selected_item].name, 12) 75 | else 76 | screen.text(self.items[self.selected_item], 12) 77 | end 78 | end 79 | 80 | screen.move(self.x, self.y + 20) 81 | 82 | if self.selected_item and self.items[self.selected_item + 1] then 83 | screen.level(1) 84 | if self.items[self.selected_item + 1].name then 85 | screen.text(self.items[self.selected_item + 1].name, 12) 86 | else 87 | screen.text(self.items[self.selected_item + 1], 12) 88 | end 89 | end 90 | end 91 | 92 | function vertical_scroll_selector:scroll_down() 93 | if self.selected_item < #self.items then 94 | self.selected_item = self.selected_item + 1 95 | end 96 | fn.dirty_screen(true) 97 | end 98 | 99 | function vertical_scroll_selector:scroll_up() 100 | if self.selected_item > 1 then 101 | self.selected_item = self.selected_item - 1 102 | end 103 | fn.dirty_screen(true) 104 | end 105 | 106 | function vertical_scroll_selector:scroll(direction) -- 1 for down, -1 for up 107 | if direction > 0 then 108 | for i = 1, direction do 109 | self:scroll_down() 110 | end 111 | elseif direction < 0 then 112 | for i = 1, math.abs(direction) do 113 | self:scroll_up() 114 | end 115 | end 116 | end 117 | 118 | function vertical_scroll_selector:select() 119 | self.selected = true 120 | fn.dirty_screen(true) 121 | end 122 | 123 | function vertical_scroll_selector:deselect() 124 | self.selected = false 125 | fn.dirty_screen(true) 126 | end 127 | 128 | function vertical_scroll_selector:is_selected() 129 | return self.selected 130 | end 131 | 132 | return vertical_scroll_selector 133 | -------------------------------------------------------------------------------- /lib/ui.lua: -------------------------------------------------------------------------------- 1 | local ui = {} 2 | 3 | 4 | 5 | channel_edit_page_ui = include("mosaic/lib/pages/channel_edit_page/channel_edit_page_ui") 6 | scale_edit_page_ui = include("mosaic/lib/pages/scale_edit_page/scale_edit_page_ui") 7 | velocity_edit_page_ui = include("mosaic/lib/pages/velocity_edit_page/velocity_edit_page_ui") 8 | note_edit_page_ui = include("mosaic/lib/pages/note_edit_page/note_edit_page_ui") 9 | trigger_edit_page_ui = include("mosaic/lib/pages/trigger_edit_page/trigger_edit_page_ui") 10 | song_edit_page_ui = include("mosaic/lib/pages/song_edit_page/song_edit_page_ui") 11 | 12 | tooltip = include("mosaic/lib/ui_components/tooltip") 13 | save_confirm = include("mosaic/lib/ui_components/save_confirm") 14 | 15 | is_key1_down = false 16 | is_key2_down = false 17 | is_key3_down = false 18 | 19 | function ui.init() 20 | draw:register_ui("tooltip", tooltip.draw) 21 | 22 | channel_edit_page_ui.register_ui_draws() 23 | scale_edit_page_ui.register_ui_draws() 24 | velocity_edit_page_ui.register_ui_draws() 25 | note_edit_page_ui.register_ui_draws() 26 | trigger_edit_page_ui.register_ui_draws() 27 | song_edit_page_ui.register_ui_draws() 28 | 29 | channel_edit_page_ui.init() 30 | scale_edit_page_ui.init() 31 | note_edit_page_ui.init() 32 | velocity_edit_page_ui.init() 33 | trigger_edit_page_ui.init() 34 | song_edit_page_ui.init() 35 | end 36 | 37 | function ui.redraw() 38 | if not program then 39 | return 40 | end 41 | 42 | screen.font_size(8) 43 | screen.font_face(1) 44 | screen.level(10) 45 | screen.move(120, 9) 46 | screen.text("m") 47 | draw:handle_ui(program.get_selected_page()) 48 | end 49 | 50 | function ui.enc(n, d) 51 | if program.get_selected_page() == pages.pages.channel_edit_page then 52 | channel_edit_page_ui.enc(n, d) 53 | elseif program.get_selected_page() == pages.pages.scale_edit_page then 54 | scale_edit_page_ui.enc(n, d) 55 | elseif program.get_selected_page() == pages.pages.trigger_edit_page then 56 | trigger_edit_page_ui.enc(n, d) 57 | elseif program.get_selected_page() == pages.pages.note_edit_page then 58 | note_edit_page_ui.enc(n, d) 59 | elseif program.get_selected_page() == pages.pages.velocity_edit_page then 60 | velocity_edit_page_ui.enc(n, d) 61 | elseif program.get_selected_page() == pages.pages.song_edit_page then 62 | song_edit_page_ui.enc(n, d) 63 | end 64 | end 65 | 66 | function ui.key(n, z) 67 | 68 | if n == 1 and z == 1 then 69 | is_key1_down = true 70 | elseif n == 1 and z == 0 then 71 | is_key1_down = false 72 | end 73 | 74 | if n == 2 and z == 1 then 75 | is_key2_down = true 76 | elseif n == 2 and z == 0 then 77 | is_key2_down = false 78 | end 79 | 80 | if n == 3 and z == 1 then 81 | is_key3_down = true 82 | elseif n == 3 and z == 0 then 83 | is_key3_down = false 84 | end 85 | 86 | if pages.pages.channel_edit_page == program.get_selected_page() then 87 | channel_edit_page_ui.key(n, z) 88 | elseif pages.pages.scale_edit_page == program.get_selected_page() then 89 | scale_edit_page_ui.key(n, z) 90 | elseif pages.pages.velocity_edit_page == program.get_selected_page() then 91 | -- velocity_edit_page_ui.key(n, z) 92 | elseif pages.pages.note_edit_page == program.get_selected_page() then 93 | -- note_edit_page_ui.key(n, z) 94 | elseif pages.pages.trigger_edit_page == program.get_selected_page() then 95 | -- trigger_edit_page_ui.key(n, z) 96 | elseif pages.pages.song_edit_page == program.get_selected_page() then 97 | song_edit_page_ui.key(n, z) 98 | end 99 | 100 | end 101 | 102 | function ui.refresh() 103 | channel_edit_page_ui.refresh() 104 | -- note_edit_page_ui.refresh() 105 | -- trigger_edit_page_ui.refresh() 106 | -- song_edit_page_ui.refresh() 107 | 108 | end 109 | 110 | 111 | return ui 112 | -------------------------------------------------------------------------------- /lib/helpers/drum_ops.lua: -------------------------------------------------------------------------------- 1 | local drum_ops_tables = include("mosaic/lib/helpers/drum_ops_tables") 2 | 3 | drum_ops = {} 4 | 5 | local function wrap(k, lower_bound, upper_bound) 6 | local range = upper_bound - lower_bound + 1 7 | local kx = ((k - lower_bound) % range) 8 | if kx < 0 then 9 | return upper_bound + 1 + kx 10 | else 11 | return lower_bound + kx 12 | end 13 | end 14 | 15 | local function get_byte(a, n) 16 | return a[bit32.rshift(n - 1, 3) + 1] 17 | end 18 | 19 | local function get_bit(a, k) 20 | local byte = get_byte(a, k) 21 | local bit_index = 7 - ((k - 1) % 8) 22 | return bit32.band(byte, bit32.lshift(1, bit_index)) ~= 0 23 | end 24 | 25 | function drum_ops.tresillo(bank, pattern1, pattern2, len, step) 26 | if bank < 1 or bank > 5 then 27 | return 1 28 | end 29 | if len < 8 then 30 | return 1 31 | end 32 | if step < 1 then 33 | return 1 34 | end 35 | if pattern1 > drum_ops_tables.drum_ops_pattern_len or pattern2 > drum_ops_tables.drum_ops_pattern_len then 36 | return 1 37 | end 38 | 39 | local table1 40 | local table2 41 | 42 | if bank == 1 then 43 | table1 = drum_ops_tables.table_t_r_e[pattern1] 44 | table2 = drum_ops_tables.table_t_r_e[pattern2] 45 | elseif bank == 2 then 46 | table1 = drum_ops_tables.table_dr_bd[pattern1] 47 | table2 = drum_ops_tables.table_dr_bd[pattern2] 48 | elseif bank == 3 then 49 | table1 = drum_ops_tables.table_dr_sd[pattern1] 50 | table2 = drum_ops_tables.table_dr_sd[pattern2] 51 | elseif bank == 4 then 52 | table1 = drum_ops_tables.table_dr_ch[pattern1] 53 | table2 = drum_ops_tables.table_dr_ch[pattern2] 54 | elseif bank == 5 then 55 | table1 = drum_ops_tables.table_dr_oh[pattern1] 56 | table2 = drum_ops_tables.table_dr_oh[pattern2] 57 | end 58 | 59 | local multiplier = math.floor(len / 8) 60 | 61 | local three = 3 * multiplier 62 | local wrapped_step = wrap(step, 1, multiplier * 8) 63 | 64 | if wrapped_step <= three then 65 | return get_bit(table1, wrapped_step) 66 | elseif wrapped_step <= three * 2 then 67 | return get_bit(table1, wrapped_step - three) 68 | end 69 | 70 | return get_bit(table2, wrapped_step - (three * 2)) 71 | end 72 | 73 | function drum_ops.drum(bank, pattern, step) 74 | if bank < 1 or bank > 5 then 75 | return 1 76 | end 77 | if step < 1 then 78 | return 1 79 | end 80 | if pattern > drum_ops_tables.drum_ops_pattern_len then 81 | return 1 82 | end 83 | 84 | local table 85 | 86 | if bank == 1 then 87 | table = drum_ops_tables.table_t_r_e[pattern] 88 | elseif bank == 2 then 89 | table = drum_ops_tables.table_dr_bd[pattern] 90 | elseif bank == 3 then 91 | table = drum_ops_tables.table_dr_sd[pattern] 92 | elseif bank == 4 then 93 | table = drum_ops_tables.table_dr_ch[pattern] 94 | elseif bank == 5 then 95 | table = drum_ops_tables.table_dr_oh[pattern] 96 | end 97 | 98 | local wrapped_step = wrap(step, 1, 16) 99 | return get_bit(table, wrapped_step) 100 | end 101 | 102 | function drum_ops.nr(prime, mask, factor, step) 103 | if prime < 1 then 104 | prime = 32 + prime 105 | end 106 | local rhythm = drum_ops_tables.table_nr[prime] 107 | if mask < 1 then 108 | mask = 4 + mask 109 | end 110 | if factor < 1 then 111 | factor = 17 + factor 112 | end 113 | if step < 1 then 114 | step = 16 + step 115 | end 116 | step = wrap(step, 1, 16) 117 | if mask == 1 then 118 | rhythm = bit32.band(rhythm, 0x0F0F) 119 | elseif mask == 2 then 120 | rhythm = bit32.band(rhythm, 0xF003) 121 | elseif mask == 3 then 122 | rhythm = bit32.band(rhythm, 0x1F0) 123 | end 124 | 125 | local modified = rhythm * factor 126 | 127 | local final = bit32.bor(bit32.band(modified, 0xFFFF), bit32.rshift(modified, 16)) 128 | local bit_status = bit32.band(bit32.rshift(final, 16 - step), 1) 129 | 130 | return bit_status == 1 131 | end 132 | 133 | return drum_ops 134 | -------------------------------------------------------------------------------- /lib/ui_components/memory_history_navigator.lua: -------------------------------------------------------------------------------- 1 | local memory_history_navigator = {} 2 | memory_history_navigator.__index = memory_history_navigator 3 | 4 | 5 | 6 | function memory_history_navigator:new(x, y, name) 7 | local self = setmetatable({}, memory_history_navigator) 8 | self.x = x 9 | self.y = y 10 | self.name = name 11 | self.current_index = 0 12 | self.max_index = 0 13 | self.event_state = nil 14 | self.selected = false 15 | return self 16 | end 17 | 18 | function memory_history_navigator:draw() 19 | if self.selected then 20 | screen.level(15) 21 | else 22 | screen.level(1) 23 | end 24 | screen.move(self.x, self.y + 5) 25 | screen.font_size(10) 26 | screen.text(self.current_index) 27 | screen.move(self.x, self.y + 17) 28 | screen.font_size(8) 29 | screen.text("of") 30 | screen.move(self.x, self.y + 31) 31 | screen.font_size(10) 32 | screen.text(self.max_index) 33 | 34 | if self.event_state.events then 35 | -- Initialize an array to store fixed y positions 36 | local fixed_y_positions = {} 37 | local last_valid_y_pos = 60 -- Default value if no valid note is found 38 | 39 | -- First pass: determine fixed y positions for all events 40 | for i = #self.event_state.events, 1, -1 do -- Scan backwards 41 | local event = self.event_state.events[i] 42 | if event.type == "note_mask" then 43 | local note = event.data and event.data.event_data and event.data.event_data.note 44 | if note and note > -1 then 45 | fixed_y_positions[i] = note 46 | last_valid_y_pos = note 47 | else 48 | fixed_y_positions[i] = last_valid_y_pos 49 | end 50 | end 51 | end 52 | 53 | -- Second pass: draw using fixed positions 54 | for i, event in ipairs(self.event_state.events) do 55 | if i > 15 then 56 | break 57 | end 58 | local y_pos = fixed_y_positions[i] or last_valid_y_pos -- Fallback to last_valid_y_pos if nil 59 | screen.font_size(5) 60 | screen.font_face(60) 61 | 62 | if event.type == "note_mask" then 63 | local note = event.data and event.data.event_data and event.data.event_data.note 64 | local length = event.data and event.data.event_data and event.data.event_data.length 65 | local velocity = event.data and event.data.event_data and event.data.event_data.velocity or 120 66 | local chord_degrees = event.data and event.data.event_data and event.data.event_data.chord_degrees 67 | 68 | if y_pos then -- Safety check 69 | screen.level(math.floor(velocity / 10) + 3) 70 | screen.move(self.x + 120 - (i * 5), self.y + 40 - (y_pos / 3)) 71 | if note and note > -1 then 72 | screen.text("\u{286}") 73 | elseif chord_degrees and fn.table_count(chord_degrees) > 0 then 74 | screen.text("'") 75 | elseif length then 76 | screen.text(".") 77 | end 78 | end 79 | elseif event.type == "trig_lock" then 80 | local y_pos = fixed_y_positions[i] or last_valid_y_pos -- Fallback to last_valid_y_pos if nil 81 | if y_pos then 82 | screen.level(15) 83 | screen.move(self.x + 120 - (i * 5), self.y + 40 - (y_pos / 3)) 84 | screen.text("T") 85 | end 86 | end 87 | screen.font_face(1) 88 | screen.font_size(8) 89 | end 90 | end 91 | end 92 | 93 | function memory_history_navigator:select() 94 | self.selected = true 95 | fn.dirty_screen(true) 96 | end 97 | 98 | function memory_history_navigator:deselect() 99 | self.selected = false 100 | fn.dirty_screen(true) 101 | end 102 | 103 | function memory_history_navigator:is_selected() 104 | return self.selected 105 | end 106 | 107 | function memory_history_navigator:get_current_index() 108 | return self.current_index 109 | end 110 | 111 | function memory_history_navigator:set_current_index(v) 112 | self.current_index = v or 0 113 | fn.dirty_screen(true) 114 | end 115 | 116 | function memory_history_navigator:get_max_index() 117 | return self.max_index 118 | end 119 | 120 | function memory_history_navigator:set_max_index(v) 121 | self.max_index = v or 0 122 | fn.dirty_screen(true) 123 | end 124 | 125 | function memory_history_navigator:set_event_state(event_state) 126 | self.event_state = event_state 127 | end 128 | 129 | return memory_history_navigator 130 | -------------------------------------------------------------------------------- /lib/config/ex_m_sample.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "params": [ 3 | { 4 | "cc_max_value": 127, 5 | "off_value": -1, 6 | "short_descriptor_2": "FLDR", 7 | "name": "Folder", 8 | "short_descriptor_1": "SMPL", 9 | "cc_min_value": -1, 10 | "cc_msb": 7, 11 | "id": "folder" 12 | }, 13 | { 14 | "cc_max_value": 127, 15 | "off_value": -1, 16 | "short_descriptor_2": "TIME", 17 | "name": "Attack Time", 18 | "short_descriptor_1": "ATCK", 19 | "cc_min_value": -1, 20 | "cc_msb": 8, 21 | "id": "attack_time" 22 | }, 23 | { 24 | "cc_max_value": 127, 25 | "off_value": -1, 26 | "short_descriptor_2": "TIME", 27 | "name": "Decay Time", 28 | "short_descriptor_1": "DCAY", 29 | "cc_min_value": -1, 30 | "cc_msb": 9, 31 | "id": "decay_time" 32 | }, 33 | { 34 | "cc_max_value": 127, 35 | "off_value": -1, 36 | "short_descriptor_2": "TIME", 37 | "name": "Sustain Time", 38 | "short_descriptor_1": "SUST", 39 | "cc_min_value": -1, 40 | "cc_msb": 10, 41 | "id": "sustain_time" 42 | }, 43 | { 44 | "cc_max_value": 127, 45 | "off_value": -1, 46 | "short_descriptor_2": "TIME", 47 | "name": "Release Time", 48 | "short_descriptor_1": "RELE", 49 | "cc_min_value": -1, 50 | "cc_msb": 11, 51 | "id": "release_time" 52 | }, 53 | { 54 | "cc_max_value": 127, 55 | "off_value": -1, 56 | "short_descriptor_2": "AVE", 57 | "name": "Octave", 58 | "short_descriptor_1": "OCT", 59 | "cc_min_value": -1, 60 | "cc_msb": 12, 61 | "id": "octave" 62 | }, 63 | { 64 | "cc_max_value": 127, 65 | "off_value": -1, 66 | "short_descriptor_2": "SPOS", 67 | "name": "Transpose", 68 | "short_descriptor_1": "TRAN", 69 | "cc_min_value": -1, 70 | "cc_msb": 13, 71 | "id": "transpose" 72 | }, 73 | { 74 | "cc_max_value": 127, 75 | "off_value": -1, 76 | "short_descriptor_2": "GAIN", 77 | "name": "Gain", 78 | "short_descriptor_1": "AMP", 79 | "cc_min_value": -1, 80 | "cc_msb": 15, 81 | "id": "gain" 82 | }, 83 | { 84 | "cc_max_value": 127, 85 | "off_value": -1, 86 | "short_descriptor_2": "TION", 87 | "name": "Saturation", 88 | "short_descriptor_1": "STUR", 89 | "cc_min_value": -1, 90 | "cc_msb": 16, 91 | "id": "saturation" 92 | }, 93 | { 94 | "cc_max_value": 127, 95 | "off_value": -1, 96 | "short_descriptor_2": "MODE", 97 | "name": "Delay Mode", 98 | "short_descriptor_1": "DLAY", 99 | "cc_min_value": -1, 100 | "cc_msb": 51, 101 | "id": "delay_mode" 102 | }, 103 | { 104 | "cc_max_value": 127, 105 | "off_value": -1, 106 | "short_descriptor_2": "LVL", 107 | "name": "Delay Level", 108 | "short_descriptor_1": "DLAY", 109 | "cc_min_value": -1, 110 | "cc_msb": 52, 111 | "id": "delay_level" 112 | }, 113 | { 114 | "cc_max_value": 127, 115 | "off_value": -1, 116 | "short_descriptor_2": "TIME", 117 | "name": "Delay Time", 118 | "short_descriptor_1": "DLAY", 119 | "cc_min_value": -1, 120 | "cc_msb": 53, 121 | "id": "delay_time" 122 | }, 123 | { 124 | "cc_max_value": 127, 125 | "off_value": -1, 126 | "short_descriptor_2": "TIME", 127 | "name": "Delay Feedback", 128 | "short_descriptor_1": "DLAY", 129 | "cc_min_value": -1, 130 | "cc_msb": 54, 131 | "id": "delay_feedback" 132 | }, 133 | { 134 | "cc_max_value": 127, 135 | "off_value": -1, 136 | "short_descriptor_2": "BASS", 137 | "name": "Tone Bass", 138 | "short_descriptor_1": "TONE", 139 | "cc_min_value": -1, 140 | "cc_msb": 55, 141 | "id": "tone_base" 142 | }, 143 | { 144 | "cc_max_value": 127, 145 | "off_value": -1, 146 | "short_descriptor_2": "TRBL", 147 | "name": "Tone Treble", 148 | "short_descriptor_1": "TONE", 149 | "cc_min_value": -1, 150 | "cc_msb": 56, 151 | "id": "tone_treble" 152 | } 153 | ], 154 | "name": "EX M Sample", 155 | "type": "midi", 156 | "value": 6, 157 | "polyphonic": false, 158 | "map_params_automatically": false, 159 | "id": "ex-multi-sample" 160 | }] -------------------------------------------------------------------------------- /lib/press.lua: -------------------------------------------------------------------------------- 1 | local press = {} 2 | 3 | 4 | -- Create a table for the handlers 5 | press.handlers = {} 6 | press.dual_handlers = {} 7 | press.long_handlers = {} 8 | press.pre_handlers = {} 9 | press.post_handlers = {} 10 | 11 | -- Register a function with a page 12 | function press:register(page, func) 13 | -- If no functions have been registered for this page yet, create a new list 14 | if self.handlers[page] == nil then 15 | self.handlers[page] = {} 16 | end 17 | 18 | -- Add the function to the list of handlers for this page 19 | table.insert(self.handlers[page], func) 20 | end 21 | 22 | -- Register a function with a page 23 | function press:register_dual(page, func) 24 | -- If no functions have been registered for this page yet, create a new list 25 | if self.dual_handlers[page] == nil then 26 | self.dual_handlers[page] = {} 27 | end 28 | 29 | -- Add the function to the list of handlers for this page 30 | table.insert(self.dual_handlers[page], func) 31 | end 32 | 33 | function press:register_long(page, func) 34 | -- If no functions have been registered for this page yet, create a new list 35 | if self.long_handlers[page] == nil then 36 | self.long_handlers[page] = {} 37 | end 38 | 39 | -- Add the function to the list of handlers for this page 40 | table.insert(self.long_handlers[page], func) 41 | end 42 | 43 | function press:register_pre(page, func) 44 | -- If no functions have been registered for this page yet, create a new list 45 | if self.pre_handlers[page] == nil then 46 | self.pre_handlers[page] = {} 47 | end 48 | 49 | -- Add the function to the list of handlers for this page 50 | table.insert(self.pre_handlers[page], func) 51 | end 52 | 53 | function press:register_post(page, func) 54 | -- If no functions have been registered for this page yet, create a new list 55 | if self.post_handlers[page] == nil then 56 | self.post_handlers[page] = {} 57 | end 58 | 59 | -- Add the function to the list of handlers for this page 60 | table.insert(self.post_handlers[page], func) 61 | end 62 | 63 | -- Call all functions registered with a page 64 | function press:handle(page, x, y) 65 | 66 | local found_page = fn.find_key(pages.pages, page) 67 | 68 | -- Call all menu press handlers 69 | for _, func in ipairs(self.handlers["menu"]) do 70 | func(x, y) 71 | end 72 | 73 | -- If no functions have been registered for this page, do nothing 74 | if self.handlers[found_page] == nil then 75 | return 76 | end 77 | 78 | -- Otherwise, call all functions registered for this page 79 | for _, func in ipairs(self.handlers[found_page]) do 80 | func(x, y) 81 | end 82 | 83 | save_confirm.cancel() 84 | autosave_reset() 85 | end 86 | 87 | -- Call all functions registered with a page 88 | function press:handle_dual(page, x, y, x2, y2) 89 | 90 | local found_page = fn.find_key(pages.pages, page) 91 | 92 | -- If no functions have been registered for this page, do nothing 93 | if self.dual_handlers[found_page] == nil then 94 | return 95 | end 96 | 97 | -- Otherwise, call all functions registered for this page 98 | for _, func in ipairs(self.dual_handlers[found_page]) do 99 | func(x, y, x2, y2) 100 | end 101 | 102 | autosave_reset() 103 | end 104 | 105 | function press:handle_long(page, x, y) 106 | 107 | local found_page = fn.find_key(pages.pages, page) 108 | 109 | -- Call all menu press handlers 110 | for _, func in ipairs(self.long_handlers["menu"]) do 111 | func(x, y) 112 | end 113 | 114 | -- If no functions have been registered for this page, do nothing 115 | if self.long_handlers[found_page] == nil then 116 | return 117 | end 118 | 119 | -- Otherwise, call all functions registered for this page 120 | for _, func in ipairs(self.long_handlers[found_page]) do 121 | func(x, y) 122 | end 123 | 124 | autosave_reset() 125 | end 126 | 127 | function press:handle_pre(page, x, y) 128 | 129 | local found_page = fn.find_key(pages.pages, page) 130 | 131 | -- If no functions have been registered for this page, do nothing 132 | if self.pre_handlers[found_page] == nil then 133 | return 134 | end 135 | 136 | -- Otherwise, call all functions registered for this page 137 | for _, func in ipairs(self.pre_handlers[fn.find_key(pages.pages, page)]) do 138 | func(x, y) 139 | end 140 | end 141 | 142 | function press:handle_post(page, x, y) 143 | 144 | local found_page = fn.find_key(pages.pages, page) 145 | 146 | -- If no functions have been registered for this page, do nothing 147 | if self.post_handlers[found_page] == nil then 148 | return 149 | end 150 | 151 | -- Otherwise, call all functions registered for this page 152 | for _, func in ipairs(self.post_handlers[found_page]) do 153 | func(x, y) 154 | end 155 | end 156 | 157 | return press 158 | -------------------------------------------------------------------------------- /lib/tests/run_tests.lua: -------------------------------------------------------------------------------- 1 | -- require('luacov') 2 | 3 | -- Function to check if the norns directory exists with extracted files 4 | function directory_and_files_exist(path, sample_file_name) 5 | local check_command = string.format('find "%s" -type f -name "%s"', path, sample_file_name) 6 | local handle = io.popen(check_command) 7 | local result = handle:read("*a") 8 | handle:close() 9 | return result ~= "" 10 | end 11 | 12 | local expected_file_name = "norns.lua" 13 | 14 | local my_path = "./test_artefacts/norns_test_artefact/lua/lib/?.lua;" 15 | 16 | if directory_and_files_exist("/home/we/norns/lua/core", expected_file_name) then 17 | print("Running these tests directly on norns can cause issues. Skipping.") 18 | return 19 | end 20 | 21 | if directory_and_files_exist("./test_artefacts/norns_test_artefact/lua/core", expected_file_name) then 22 | print("The './test_artefacts/norns_test_artefact/lua/core/norns.lua' file already exists. Skipping download.") 23 | else 24 | print("Fetching latest release of norns...") 25 | 26 | -- Ensure the test_artefacts directory exists 27 | os.execute("mkdir -p ./test_artefacts") 28 | 29 | -- Fetch the latest release data from GitHub and save it to a file within test_artefacts 30 | os.execute("curl -s https://api.github.com/repos/monome/norns/releases/latest > ./test_artefacts/latest_release.json") 31 | 32 | -- Read the file and extract the download URL 33 | local file = io.open("./test_artefacts/latest_release.json", "r") 34 | local content = file:read("*all") 35 | file:close() 36 | 37 | -- Attempt to extract the zipball download URL using Lua pattern matching 38 | local download_url = content:match('"zipball_url":%s*"([^"]+)"') 39 | 40 | if download_url then 41 | -- Download the latest release zip file into test_artefacts 42 | os.execute(string.format("curl -L '%s' -o ./test_artefacts/norns_latest.zip", download_url)) 43 | 44 | -- Extract the zip file into a temporary directory within test_artefacts 45 | os.execute("unzip ./test_artefacts/norns_latest.zip -d ./test_artefacts/temp_norns") 46 | 47 | -- Determine the name of the top-level directory 48 | local top_level_dir_command = "ls ./test_artefacts/temp_norns | head -n 1" 49 | local handle = io.popen(top_level_dir_command) 50 | local top_level_dir = handle:read("*a"):gsub("\n", "") 51 | handle:close() 52 | 53 | -- Move the contents from the top-level directory to the desired location and clean up 54 | if top_level_dir ~= "" then 55 | os.execute("mkdir -p ./test_artefacts/norns_test_artefact") 56 | os.execute(string.format("mv ./test_artefacts/temp_norns/%s/* ./test_artefacts/norns_test_artefact", top_level_dir)) 57 | os.execute("rm -rf ./test_artefacts/temp_norns") 58 | print("norns has been successfully downloaded and extracted to './test_artefacts/norns_test_artefact'.") 59 | else 60 | print("Failed to identify the top-level directory within the zip archive.") 61 | end 62 | else 63 | print("Failed to extract the download URL from the JSON response.") 64 | end 65 | end 66 | 67 | package.path = my_path .. package.path 68 | 69 | util = require('util') 70 | luaunit = require('test.luaunit') 71 | 72 | -- global include function 73 | function include(file) 74 | local dirs = {'../../../', './test_artefacts/norns_test_artefact/lua/extn/'} 75 | for _, dir in ipairs(dirs) do 76 | local p = dir..file..'.lua' 77 | if util.file_exists(p) then 78 | return dofile(p) 79 | end 80 | end 81 | 82 | -- didn't find anything 83 | print("### MISSING INCLUDE: "..file) 84 | error("MISSING INCLUDE: "..file,2) 85 | end 86 | 87 | function require_all_files_in_folder(folder) 88 | local command = string.format('ls %s/*.lua', folder) 89 | local handle = io.popen(command) 90 | local result = handle:read("*a") 91 | handle:close() 92 | 93 | -- Iterate through each line in the result 94 | for filename in string.gmatch(result, '[^\r\n]+') do 95 | -- Remove the directory path and extension from the filename to get the module name 96 | local module = filename:match("^.+/(.+).lua$") 97 | if module then 98 | local module_path = folder .. '.' .. module 99 | require(module_path:gsub('/', '.')) 100 | end 101 | end 102 | end 103 | 104 | function clear_require_cache(modules) 105 | if modules then 106 | for _, module_name in ipairs(modules) do 107 | package.loaded[module_name] = nil 108 | end 109 | else 110 | for module_name in pairs(package.loaded) do 111 | package.loaded[module_name] = nil 112 | end 113 | end 114 | end 115 | 116 | clear_require_cache() 117 | 118 | fn = include("mosaic/lib/helpers/functions") 119 | pages = include("mosaic/lib/pages/pages") 120 | scheduler = include("mosaic/lib/tests/helpers/mocks/scheduler_mock") 121 | program = include("mosaic/lib/models/program") 122 | memory = include("mosaic/lib/memory") 123 | include("mosaic/lib/tests/helpers/globals") 124 | 125 | require_all_files_in_folder('./lib') 126 | require_all_files_in_folder('./lib/integration_tests') 127 | 128 | os.exit( luaunit.LuaUnit.run() ) -------------------------------------------------------------------------------- /lib/tests/run_tests_windows.lua: -------------------------------------------------------------------------------- 1 | -- require('luacov') 2 | 3 | -- Windows compatible function to check if the directory exists with extracted files 4 | function directory_and_files_exist(path, sample_file_name) 5 | local check_command = string.format('powershell -Command "(Get-ChildItem -Path \'%s\' -Recurse -Filter \'%s\' -ErrorAction SilentlyContinue).FullName"', path, sample_file_name) 6 | local handle = io.popen(check_command) 7 | local result = handle:read("*a") 8 | handle:close() 9 | return result and result ~= "" 10 | end 11 | 12 | 13 | if directory_and_files_exist(".\\test_artefacts\\norns_test_artefact\\lua\\core", "norns.lua") then 14 | print("The '.\\test_artefacts\\norns_test_artefact\\lua\\core\\norns.lua' file already exists. Skipping download.") 15 | else 16 | 17 | 18 | -- Ensure the test_artefacts directory exists 19 | os.execute("if not exist .\\test_artefacts mkdir .\\test_artefacts") 20 | 21 | if directory_and_files_exist(".\\test_artefacts\\", "norns_latest.zip") then 22 | print("The '.\\test_artefacts\\norns_test_artefact\\norns_latest.zip' file already exists. Skipping download.") 23 | else 24 | print("Fetching latest release of norns...") 25 | -- Fetch the latest release data from GitHub and save it to a file within test_artefacts 26 | os.execute("powershell -Command \"Invoke-WebRequest -Uri https://api.github.com/repos/monome/norns/releases/latest -OutFile .\\test_artefacts\\latest_release.json\"") 27 | 28 | -- Read the file and extract the download URL 29 | local file = io.open(".\\test_artefacts\\latest_release.json", "r") 30 | local content = file:read("*all") 31 | file:close() 32 | 33 | 34 | 35 | -- Attempt to extract the zipball download URL using Lua pattern matching 36 | local download_url = content:match('"zipball_url":%s*"([^"]+)"') 37 | 38 | if download_url then 39 | -- Download the latest release zip file into test_artefacts 40 | os.execute(string.format("powershell -Command \"Invoke-WebRequest -Uri '%s' -OutFile .\\test_artefacts\\norns_latest.zip\"", download_url)) 41 | end 42 | end 43 | 44 | -- Step 1: Extract the archive to a temporary directory 45 | local extract_command = "powershell -Command \"Expand-Archive -Path .\\test_artefacts\\norns_latest.zip -DestinationPath .\\test_artefacts\\temp_norns\"" 46 | os.execute(extract_command) 47 | 48 | -- Move with overwrite 49 | local move_command = [[powershell -Command "$source = (Get-ChildItem -Path .\test_artefacts\temp_norns | Select-Object -First 1).FullName; Move-Item -Path $source\* -Destination .\test_artefacts\norns_test_artefact -Force"]] 50 | os.execute(move_command) 51 | 52 | -- Remove read-only attributes and then attempt removal 53 | local prepare_remove_command = [[powershell -Command "Get-ChildItem -Path .\test_artefacts\temp_norns -Recurse | ForEach-Object { $_.Attributes = 'Normal' }"]] 54 | os.execute(prepare_remove_command) 55 | 56 | local cleanup_command = "powershell -Command \"Remove-Item -Path .\\test_artefacts\\temp_norns -Recurse -Force\"" 57 | os.execute(cleanup_command) 58 | 59 | 60 | end 61 | 62 | local my_path = ".\\test_artefacts\\norns_test_artefact\\lua\\lib\\?.lua;" 63 | package.path = my_path .. package.path 64 | 65 | util = require('util') 66 | luaunit = require('test.luaunit') 67 | 68 | -- global include function 69 | function include(file) 70 | local dirs = {'..\\..\\..\\', '.\\test_artefacts\\norns_test_artefact\\lua\\extn\\'} 71 | for _, dir in ipairs(dirs) do 72 | local p = dir..file..'.lua' 73 | if util.file_exists(p) then 74 | return dofile(p) 75 | end 76 | end 77 | 78 | -- didn't find anything 79 | print("### MISSING INCLUDE: "..file) 80 | error("MISSING INCLUDE: "..file,2) 81 | end 82 | 83 | function require_all_files_in_folder(folder) 84 | -- Adapted for Windows: Use PowerShell to list all Lua files in the folder 85 | local command = string.format('powershell -Command "Get-ChildItem -Path \'%s\' -Filter \'*.lua\' | ForEach-Object { echo $_.Name }"', folder) 86 | local handle = io.popen(command) 87 | local result = handle:read("*a") 88 | handle:close() 89 | 90 | -- Iterate through each line in the result to require the Lua files 91 | for filename in string.gmatch(result, '[^\r\n]+') do 92 | -- Adapt for Windows: Extract the module name from the filename 93 | local module = filename:match("^(.+).lua$") 94 | if module then 95 | local module_path = folder .. '\\' .. module 96 | require(module_path:gsub('\\', '.')) 97 | end 98 | end 99 | end 100 | 101 | function clear_require_cache(modules) 102 | if modules then 103 | for _, module_name in ipairs(modules) do 104 | package.loaded[module_name] = nil 105 | end 106 | else 107 | for module_name in pairs(package.loaded) do 108 | package.loaded[module_name] = nil 109 | end 110 | end 111 | end 112 | 113 | clear_require_cache() 114 | 115 | fn = include("mosaic\\lib\\helpers\\functions") 116 | pages = include("mosaic\\lib\\pages\\pages") 117 | scheduler = include("mosaic\\lib\\tests\\helpers\\mocks\\scheduler_mock") 118 | program = include("mosaic\\lib\\models\\program") 119 | memory = include("mosaic\\lib\\memory") 120 | 121 | include("mosaic\\lib\\tests\\helpers\\globals") 122 | 123 | require_all_files_in_folder('.\\lib') 124 | require_all_files_in_folder('.\\lib\\integration_tests') 125 | 126 | os.exit(luaunit.LuaUnit.run()) 127 | 128 | -------------------------------------------------------------------------------- /lib/recorder.lua: -------------------------------------------------------------------------------- 1 | local recorder = {} 2 | 3 | -- Stores portions of mask events for consolidation 4 | recorder.mask_events = {} 5 | recorder.trig_lock_events = {} 6 | recorder.trig_lock_dirty = {} 7 | 8 | local function init_trig_lock_dirty() 9 | for i = 1, 16 do 10 | recorder.trig_lock_dirty[i] = {} 11 | for j = 1, 10 do 12 | recorder.trig_lock_dirty[i][j] = false 13 | end 14 | end 15 | end 16 | 17 | init_trig_lock_dirty() 18 | 19 | function recorder.add_note_mask_event_portion(c, step, event_portion) 20 | if not recorder.mask_events[c] then 21 | recorder.mask_events[c] = {} 22 | end 23 | if not recorder.mask_events[c][step] then 24 | recorder.mask_events[c][step] = {} 25 | end 26 | 27 | recorder.mask_events[c][step] = fn.deep_merge_tables(recorder.mask_events[c][step], event_portion) 28 | 29 | end 30 | 31 | function recorder.record_stored_note_mask_events(c, step) 32 | if recorder.mask_events and recorder.mask_events[c] and recorder.mask_events[c][step] then 33 | local event = recorder.mask_events[c][step] 34 | 35 | if not event.data then 36 | return 37 | end 38 | 39 | memory.record_event(c, "note_mask", event.data) 40 | recorder.mask_events[c][step] = nil 41 | 42 | end 43 | end 44 | 45 | function recorder.add_trig_lock_event_portion(c, step, event_portion) 46 | if not recorder.trig_lock_events[c] then 47 | recorder.trig_lock_events[c] = {} 48 | end 49 | if not recorder.trig_lock_events[c][step] then 50 | recorder.trig_lock_events[c][step] = {} 51 | end 52 | 53 | recorder.trig_lock_events[c][step] = fn.deep_merge_tables(recorder.trig_lock_events[c][step], event_portion) 54 | 55 | end 56 | 57 | function recorder.record_stored_trig_lock_events(c, step) 58 | if recorder.trig_lock_events and recorder.trig_lock_events[c] and recorder.trig_lock_events[c][step] then 59 | local event = recorder.trig_lock_events[c][step] 60 | 61 | if not event.data then 62 | return 63 | end 64 | 65 | memory.record_event(c, "trig_lock", event.data) 66 | recorder.trig_lock_events[c][step] = nil 67 | 68 | end 69 | end 70 | 71 | function recorder.handle_note_midi_message(note, velocity, chord_number, chord_degree) 72 | local pressed_keys = m_grid.get_pressed_keys() 73 | local channel = program.get_selected_channel() 74 | if #pressed_keys > 0 then 75 | if (pressed_keys[1][2] > 3 and pressed_keys[1][2] < 8) then 76 | 77 | local s = fn.calc_grid_count(pressed_keys[1][1], pressed_keys[1][2]) 78 | if chord_number == 1 then 79 | recorder.add_note_mask_event_portion( 80 | channel.number, 81 | s, 82 | { 83 | song_pattern = program.get().selected_song_pattern, 84 | data = { 85 | trig = 1, 86 | note = note, 87 | velocity = velocity, 88 | length = 1, 89 | chord_degrees = {nil, nil, nil, nil}, 90 | step = s 91 | } 92 | } 93 | ) 94 | elseif (chord_degree) then 95 | local chord = {} 96 | chord[chord_number - 1] = chord_degree 97 | recorder.add_note_mask_event_portion( 98 | channel.number, 99 | s, 100 | { 101 | song_pattern = program.get().selected_song_pattern, 102 | data = { 103 | step = s, 104 | chord_degrees = chord 105 | } 106 | } 107 | ) 108 | end 109 | 110 | end 111 | elseif params:get("record") == 2 then 112 | local s = program.get_current_step_for_channel(channel.number) 113 | if chord_number == 1 then 114 | recorder.add_note_mask_event_portion( 115 | channel.number, 116 | s, 117 | { 118 | song_pattern = program.get().selected_song_pattern, 119 | data = { 120 | trig = 1, 121 | note = note, 122 | velocity = velocity, 123 | length = 1, 124 | chord_degrees = {nil, nil, nil, nil}, 125 | step = s 126 | } 127 | } 128 | ) 129 | elseif (chord_degree) then 130 | local chord = {} 131 | chord[chord_number - 1] = chord_degree 132 | recorder.add_note_mask_event_portion( 133 | channel.number, 134 | s, 135 | { 136 | song_pattern = program.get().selected_song_pattern, 137 | data = { 138 | step = s, 139 | chord_degrees = chord 140 | } 141 | } 142 | ) 143 | end 144 | end 145 | end 146 | 147 | function recorder.record_trig_event(c, step, parameter) 148 | if recorder.trig_lock_dirty[c] and recorder.trig_lock_dirty[c][parameter] then 149 | memory.record_event(c, "trig_lock", { 150 | parameter = parameter, 151 | step = step, 152 | value = recorder.trig_lock_dirty[c][parameter] 153 | }) 154 | end 155 | end 156 | 157 | function recorder.trig_lock_is_dirty(c, parameter) 158 | return recorder.trig_lock_dirty[c] and recorder.trig_lock_dirty[c][parameter] 159 | end 160 | 161 | function recorder.set_trig_lock_dirty(c, parameter, value) 162 | recorder.trig_lock_dirty[c][parameter] = value 163 | end 164 | 165 | function recorder.clear_trig_lock_dirty(c, parameter) 166 | recorder.trig_lock_dirty[c][parameter] = false 167 | end 168 | 169 | function recorder.clear_all_trig_lock_dirty() 170 | init_trig_lock_dirty() 171 | end 172 | 173 | return recorder -------------------------------------------------------------------------------- /scripts/process_midi_csvs.py: -------------------------------------------------------------------------------- 1 | # This is designed to create tables of midi devices for use in mosaic. The scipt takes midi definition CSVs 2 | # from https://github.com/usercamp/midi/ and outputs a table file. It uses chatgpt to create two four letter tokens 3 | # designed to be used as identifiers for controls. The output needs to be edited manually as it's not perfect. 4 | 5 | 6 | import os 7 | import csv 8 | import json 9 | import openai 10 | import os 11 | import csv 12 | import json 13 | import time 14 | 15 | # Set your open AI org to enviroment variable OPENAI_API_ORG 16 | openai.organization = os.getenv("OPENAI_API_ORG") 17 | # Set your open AI API key to enviroment variable OPENAI_API_KEY 18 | openai.api_key = os.getenv("OPENAI_API_KEY") 19 | 20 | def generate_short_descriptor(full_string, retries=10, delay=5): 21 | for i in range(retries): 22 | try: 23 | response = openai.ChatCompletion.create( 24 | model="gpt-3.5-turbo-16k", 25 | temperature=0.5, 26 | messages=[ 27 | {"role": "system", "content": "You are an assistant designed to abbreviate a given input that represent concepts in modern synthesizers into two four letter uppercase tokens seperated by a '-'. Only ever respond in the format 'XXXX-XXXX'. Always respond with 9 characters in total. Fill in blanks with spaces ' '. Never return anything else. Please optimise for readability and understanding of a human musician using a synthesizer where these abbreviations are the only information they have to understand a parameter does. Use your understanding of synthesisers to appraise what is the most important information in the input. Here are some examples. 'FM parameters syn 1 Ratio A' would be 'SYN1-RATA'. 'FM parameters syn 2 A level' would be 'SYN2-ALVL'. 'Filter parameters filter type' would be 'FLTR-TYPE'. 'Amp parameters chorus send' would be 'CHOR-SEND'. 'Apm parameters amp reverb send' would be 'REVB-SEND'. 'LFO parameters LFO 2 trig mode' would be 'LFO2-TRGM'. 'FX parameters reverb highpass filter' would be 'RVRB-HPFL'. 'Amp parameters Amp delay send' would be 'DLAY-SEND'. 'Amp parameters Amp reverb send' would be 'REVB-SEND'. "}, 28 | {"role": "user", "content": full_string} 29 | ] 30 | ) 31 | result = response['choices'][0]['message']['content'].strip() 32 | print(f"Result: {result}") 33 | return result[:9] 34 | except Exception as e: 35 | print(f"An error occurred: {e}. Retrying...") 36 | time.sleep(delay) 37 | print("Failed to get result after multiple attempts.") 38 | return None 39 | 40 | def to_lua_table(input_dict): 41 | lua_table = "{\n" 42 | for key, value in input_dict.items(): 43 | if isinstance(value, str): 44 | if value == "nil": 45 | lua_table += f' ["{key}"] = nil,\n' 46 | else: 47 | lua_table += f' ["{key}"] = "{value}",\n' 48 | else: 49 | lua_table += f' ["{key}"] = {value},\n' 50 | lua_table += "}" 51 | return lua_table 52 | 53 | def create_lua_table(device, data): 54 | lua_list = "\n" 55 | for item in data: 56 | lua_list += f'{to_lua_table(item)},\n' 57 | lua_list += "" 58 | lua_table = "[\"" + f'{device}\"] = {{{lua_list}\n' 59 | return lua_table 60 | 61 | def process_csv_file(file_path): 62 | with open(file_path, 'r') as file: 63 | reader = csv.DictReader(file) 64 | processed_data = [] 65 | for row in reader: 66 | id = "{}_{}".format(row["section"].lower().replace(" ", "_"), row["parameter_name"].lower().replace(" ", "_")) 67 | name = row["parameter_name"] 68 | # Call the ChatGPT API to generate the short descriptors 69 | short_descriptors = generate_short_descriptor(row["section"] + " " + row["parameter_name"]) 70 | short_descriptor_1 = short_descriptors[:4] 71 | short_descriptor_2 = short_descriptors[-4:] 72 | processed_data.append({ 73 | "id": id, 74 | "name": name, 75 | "cc_msb": int(row["cc_msb"]) if row["cc_msb"] else "nil", 76 | "cc_lsb": int(row["cc_lsb"]) if row["cc_lsb"] else "nil", 77 | "cc_min_value": int(row["cc_min_value"]) if row["cc_min_value"] else "nil", 78 | "cc_max_value": int(row["cc_max_value"]) if row["cc_max_value"] else "nil", 79 | "nrpn_msb": int(row["nrpn_msb"]) if row["nrpn_msb"] else "nil", 80 | "nrpn_lsb": int(row["nrpn_lsb"]) if row["nrpn_lsb"] else "nil", 81 | "nrpn_min_value": int(row["nrpn_min_value"]) if row["nrpn_min_value"] else "nil", 82 | "nrpn_max_value": int(row["nrpn_max_value"]) if row["nrpn_max_value"] else "nil", 83 | "short_descriptor_1": short_descriptor_1, 84 | "short_descriptor_2": short_descriptor_2, 85 | }) 86 | return processed_data 87 | 88 | def main(): 89 | with open('output.lua', 'w') as output_file: 90 | output_file.write("local midi_devices = {") 91 | for file in os.listdir(): 92 | if file.endswith(".csv"): 93 | device = os.path.splitext(file)[0] 94 | processed_data = process_csv_file(file) 95 | lua_table = create_lua_table(device, processed_data) + "}" 96 | output_file.write(lua_table.rstrip('\n').rstrip(',') + ',\n') 97 | output_file.write("}") 98 | 99 | if __name__ == "__main__": 100 | main() 101 | -------------------------------------------------------------------------------- /lib/controls/fader.lua: -------------------------------------------------------------------------------- 1 | fader = {} 2 | fader.__index = fader 3 | 4 | function fader:new(x, y, length, size) 5 | local self = setmetatable({}, fader) 6 | self.x = x 7 | self.y = y 8 | self.length = length 9 | self.size = size 10 | self.value = 1 11 | self.is_disabled = false 12 | self.dimmed = {} 13 | 14 | self.pre_func = function() 15 | end 16 | 17 | for i = 1, self.length do 18 | self.dimmed[i] = false 19 | end 20 | 21 | return self 22 | end 23 | 24 | function fader:get_middle_button_position() 25 | -- For odd length, return the middle position 26 | -- For even length, return the lower of the two middle positions 27 | return math.floor((self.length + 1) / 2) 28 | end 29 | 30 | function fader:get_middle_value() 31 | -- Returns the middle value of the size range 32 | return math.floor((self.size + 1) / 2) 33 | end 34 | 35 | function fader:draw_simple() 36 | grid_abstraction.led(self.x, self.y, 2) 37 | 38 | for i = self.x, self.length + self.x - 1 do 39 | if self.dimmed[i] then 40 | grid_abstraction.led(i, self.y, 0) 41 | else 42 | grid_abstraction.led(i, self.y, 2) 43 | end 44 | end 45 | 46 | self.pre_func(self.x, self.y, self.length) 47 | 48 | if (self.value ~= nil and self.value > 0) then 49 | if self.dimmed[self.x + self.value - 1] then 50 | grid_abstraction.led(self.x + self.value - 1, self.y, 7) 51 | else 52 | grid_abstraction.led(self.x + self.value - 1, self.y, 15) 53 | end 54 | end 55 | end 56 | 57 | function fader:draw_fine_grain() 58 | -- Draw the fader ends 59 | grid_abstraction.led(self.x, self.y, 7) -- Decrement button 60 | grid_abstraction.led(self.length + self.x - 1, self.y, 7) -- Increment button 61 | 62 | -- Draw the fader background 63 | for i = self.x + 1, self.length + self.x - 2 do 64 | grid_abstraction.led(i, self.y, 2) 65 | end 66 | 67 | if self.value == nil then return end 68 | 69 | -- Calculate the position and brightness of the lit LEDs 70 | local total_steps = self.size 71 | local num_leds = self.length - 2 72 | local step_size = total_steps / num_leds 73 | local current_led = math.floor((self.value - 1) / step_size) 74 | local remainder = (self.value - 1) - current_led * step_size 75 | 76 | -- Ensure current_led is within bounds 77 | if current_led >= num_leds then 78 | current_led = num_leds - 1 79 | remainder = step_size 80 | end 81 | 82 | -- Handle the case when the value is at maximum 83 | if self.value == self.size then 84 | grid_abstraction.led(self.length + self.x - 2, self.y, 15) 85 | return 86 | end 87 | 88 | -- Calculate brightness 89 | local brightness = math.max(5, math.floor((remainder / step_size) * (15 - 5) + 5)) 90 | grid_abstraction.led(self.x + current_led + 1, self.y, brightness) 91 | 92 | -- Ensure the middle button is highlighted when the middle value is selected 93 | local middle_button = self:get_middle_button_position() 94 | if self.value == self:get_middle_value() then 95 | grid_abstraction.led(self.x + middle_button - 1, self.y, 15) 96 | end 97 | end 98 | 99 | function fader:draw() 100 | if self.is_disabled then 101 | return 102 | end 103 | if self.length < self.size and self.length > 2 then 104 | self:draw_fine_grain() 105 | else 106 | self:draw_simple() 107 | end 108 | end 109 | 110 | function fader:press_simple(val) 111 | self.value = val 112 | end 113 | 114 | function fader:press_fine_grain(val) 115 | local relative_pos = val - 1 -- Convert to 0-based position 116 | local middle_button = self:get_middle_button_position() - 1 -- Convert to 0-based position 117 | 118 | if relative_pos == 0 and self.value > 1 then 119 | -- First button: decrease value 120 | self.value = self.value - 1 121 | elseif relative_pos == self.length - 1 and self.value < self.size then 122 | -- Last button: increase value 123 | self.value = self.value + 1 124 | elseif relative_pos == middle_button then 125 | -- Middle button: jump to middle value 126 | self.value = self:get_middle_value() 127 | elseif relative_pos ~= 0 and relative_pos ~= self.length - 1 then 128 | -- Other positions: calculate proportional value 129 | local num_positions = self.length - 2 130 | local position = relative_pos - 1 131 | local value = math.floor((position / (num_positions - 1)) * (self.size - 1)) + 1 132 | self.value = value 133 | end 134 | end 135 | 136 | function fader:press(x, y) 137 | if x >= self.x and x <= self.x + self.length - 1 and y == self.y then 138 | if self.length < self.size then 139 | self:press_fine_grain(x - self.x + 1) 140 | else 141 | self:press_simple(x - self.x + 1) 142 | end 143 | end 144 | end 145 | 146 | -- [Rest of the methods remain unchanged] 147 | function fader:get_value() 148 | if self.is_disabled then 149 | return 0 150 | end 151 | return self.value 152 | end 153 | 154 | function fader:set_value(val) 155 | self.value = val 156 | end 157 | 158 | function fader:set_size(size) 159 | if self.value > size then 160 | self.value = size 161 | end 162 | self.size = size 163 | end 164 | 165 | function fader:set_length(length) 166 | self.length = length 167 | end 168 | 169 | function fader:set_pre_func(func) 170 | self.pre_func = func 171 | end 172 | 173 | function fader:disabled() 174 | self.is_disabled = true 175 | end 176 | 177 | function fader:enabled() 178 | self.is_disabled = false 179 | end 180 | 181 | function fader:is_this(x, y) 182 | if x >= self.x and x <= self.x + self.length - 1 and y == self.y then 183 | return true 184 | end 185 | return false 186 | end 187 | 188 | function fader:dim(x) 189 | self.dimmed[x] = true 190 | end 191 | 192 | function fader:light(x) 193 | self.dimmed[x] = false 194 | end 195 | 196 | return fader 197 | -------------------------------------------------------------------------------- /lib/devices/nb_device_param_maps.lua: -------------------------------------------------------------------------------- 1 | local nb_device_param_maps = {} 2 | 3 | local plaits_params = { 4 | "plaits_model_%d", 5 | "plaits_harmonics_%d", 6 | "plaits_timbre_%d", 7 | "plaits_morph_%d", 8 | "plaits_fm_mod_%d", 9 | "plaits_timb_mod_%d", 10 | "plaits_morph_mod_%d", 11 | "plaits_a_%d", 12 | "plaits_d_%d", 13 | "plaits_s_%d", 14 | "plaits_r_%d", 15 | "plaits_lpg_color_%d", 16 | "plaits_amp_%d", 17 | "plaits_aux_%d", 18 | "plaits_gain_%d", 19 | "plaits_pan_%d", 20 | "plaits_send_a_%d", 21 | "plaits_send_b_%d" 22 | } 23 | 24 | local plaits_default_params = { 25 | "plaits_model_%d", 26 | "plaits_harmonics_%d", 27 | "plaits_timbre_%d", 28 | "plaits_morph_%d", 29 | "plaits_fm_mod_%d", 30 | "plaits_timb_mod_%d", 31 | "plaits_morph_mod_%d", 32 | "plaits_aux_%d", 33 | "plaits_send_a_%d", 34 | "plaits_send_b_%d" 35 | } 36 | 37 | local doubledecker_params = { 38 | "doubledecker_voices", 39 | "doubledecker_voice_spread", 40 | "doubledecker_lfo_phase_spread", 41 | "doubledecker_mix", 42 | "doubledecker_amp", 43 | "doubledecker_send_a", 44 | "doubledecker_send_b", 45 | "doubledecker_pan", 46 | "doubledecker_detune", 47 | "doubledecker_drift", 48 | "doubledecker_pitch_env", 49 | "doubledecker_portomento", 50 | "doubledecker_brilliance", 51 | "doubledecker_resonance", 52 | -- Layer specific params (repeated for layers 1 and 2) 53 | "doubledecker_pitch_ratio_1", 54 | "doubledecker_pitch_ratio_2", 55 | "doubledecker_layer_lfo_freq_1", 56 | "doubledecker_layer_lfo_freq_2", 57 | "doubledecker_pwm_1", 58 | "doubledecker_pwm_2", 59 | "doubledecker_pw_1", 60 | "doubledecker_pw_2", 61 | "doubledecker_shape_1", 62 | "doubledecker_shape_2", 63 | "doubledecker_noise_1", 64 | "doubledecker_noise_2", 65 | "doubledecker_hp_freq_1", 66 | "doubledecker_hp_freq_2", 67 | "doubledecker_hp_res_1", 68 | "doubledecker_hp_res_2", 69 | "doubledecker_lp_freq_1", 70 | "doubledecker_lp_freq_2", 71 | "doubledecker_lp_res_1", 72 | "doubledecker_lp_res_2", 73 | "doubledecker_filter_init_1", 74 | "doubledecker_filter_init_2", 75 | "doubledecker_filter_attack_level_1", 76 | "doubledecker_filter_attack_level_2", 77 | "doubledecker_filter_attack_1", 78 | "doubledecker_filter_attack_2", 79 | "doubledecker_filter_decay_1", 80 | "doubledecker_filter_decay_2", 81 | "doubledecker_filter_release_1", 82 | "doubledecker_filter_release_2", 83 | "doubledecker_filt_1", 84 | "doubledecker_filt_2", 85 | "doubledecker_sine_1", 86 | "doubledecker_sine_2", 87 | "doubledecker_amp_attack_1", 88 | "doubledecker_amp_attack_2", 89 | "doubledecker_amp_decay_1", 90 | "doubledecker_amp_decay_2", 91 | "doubledecker_amp_sustain_1", 92 | "doubledecker_amp_sustain_2", 93 | "doubledecker_amp_release_1", 94 | "doubledecker_amp_release_2", 95 | "doubledecker_velocity_to_filter_1", 96 | "doubledecker_velocity_to_filter_2", 97 | "doubledecker_velocity_to_amp_1", 98 | "doubledecker_velocity_to_amp_2", 99 | "doubledecker_pressure_to_filter_1", 100 | "doubledecker_pressure_to_filter_2", 101 | "doubledecker_pressure_to_amp_1", 102 | "doubledecker_pressure_to_amp_2", 103 | "doubledecker_filter_keyfollow_lo_1", 104 | "doubledecker_filter_keyfollow_lo_2", 105 | "doubledecker_filter_keyfollow_hi_1", 106 | "doubledecker_filter_keyfollow_hi_2", 107 | "doubledecker_amp_keyfollow_lo_1", 108 | "doubledecker_amp_keyfollow_lo_2", 109 | "doubledecker_amp_keyfollow_hi_1", 110 | "doubledecker_amp_keyfollow_hi_2", 111 | "doubledecker_layer_amp_1", 112 | "doubledecker_layer_amp_2", 113 | "doubledecker_invert_hpf_1", 114 | "doubledecker_invert_hpf_2", 115 | -- LFO params 116 | "doubledecker_lfo_shape", 117 | "doubledecker_lfo_rate", 118 | "doubledecker_lfo_to_freq", 119 | "doubledecker_lfo_to_filter", 120 | "doubledecker_lfo_to_amp", 121 | "doubledecker_lfo_pres_to_freq", 122 | "doubledecker_lfo_pres_to_vibrato", 123 | "doubledecker_lfo_pres_to_filt", 124 | "doubledecker_lfo_pres_to_amp", 125 | "doubledecker_lfo_sync", 126 | "doubledecker_lfo_scope" 127 | } 128 | 129 | local doubledecker_default_params = { 130 | "doubledecker_shape_1", 131 | "doubledecker_shape_2", 132 | "doubledecker_pitch_env", 133 | "doubledecker_portomento", 134 | "doubledecker_noise_1", 135 | "doubledecker_resonance", 136 | "doubledecker_lfo_shape", 137 | "doubledecker_lfo_rate", 138 | "doubledecker_lfo_pres_to_vibrato", 139 | "doubledecker_lfo_to_filter" 140 | } 141 | 142 | local rudiments_params = { 143 | "rudiments_shape_%d", 144 | "rudiments_freq_%d", 145 | "rudiments_decay_%d", 146 | "rudiments_sweep_%d", 147 | "rudiments_lfoFreq_%d", 148 | "rudiments_lfoShape_%d", 149 | "rudiments_lfoSweep_%d", 150 | "rudiments_gain_%d" 151 | } 152 | 153 | local polyperc_params = { 154 | "polyperc_decay_%d", 155 | "polyperc_cutoff_%d", 156 | "polyperc_tracking_%d", 157 | "polyperc_pw_%d", 158 | "polyperc_amp_%d", 159 | "polyperc_gain_%d", 160 | "polyperc_pan_%d", 161 | "polyperc_send_a_%d", 162 | "polyperc_send_b_%d" 163 | } 164 | 165 | function nb_device_param_maps.get_params_for_device(device) 166 | 167 | local instance_params = {} 168 | 169 | if fn.starts_with(device, "emplait") then 170 | for _, param in ipairs(plaits_params) do 171 | table.insert(instance_params, string.format(param, fn.get_last_char(device))) 172 | end 173 | elseif device == "doubledecker" then 174 | instance_params = doubledecker_params 175 | elseif fn.starts_with(device, "rudiments") then 176 | for _, param in ipairs(rudiments_params) do 177 | table.insert(instance_params, string.format(param, fn.get_last_char(device))) 178 | end 179 | elseif fn.starts_with(device, "polyperc") then 180 | for _, param in ipairs(polyperc_params) do 181 | table.insert(instance_params, string.format(param, fn.get_last_char(device))) 182 | end 183 | end 184 | 185 | return instance_params 186 | 187 | end 188 | 189 | function nb_device_param_maps.get_default_params_for_device(device) 190 | 191 | local instance_params = {} 192 | 193 | if fn.starts_with(device, "emplait") then 194 | for _, param in ipairs(plaits_default_params) do 195 | table.insert(instance_params, string.format(param, fn.get_last_char(device))) 196 | end 197 | elseif device == "doubledecker" then 198 | instance_params = doubledecker_default_params 199 | elseif fn.starts_with(device, "rudiments") then 200 | for _, param in ipairs(rudiments_params) do 201 | table.insert(instance_params, string.format(param, fn.get_last_char(device))) 202 | end 203 | elseif fn.starts_with(device, "polyperc") then 204 | for _, param in ipairs(polyperc_params) do 205 | table.insert(instance_params, string.format(param, fn.get_last_char(device))) 206 | end 207 | end 208 | 209 | return instance_params 210 | 211 | end 212 | 213 | 214 | return nb_device_param_maps -------------------------------------------------------------------------------- /lib/pages/scale_edit_page/scale_edit_page.lua: -------------------------------------------------------------------------------- 1 | local scale_edit_page = {} 2 | 3 | local quantiser = include("mosaic/lib/quantiser") 4 | 5 | local scale_edit_page_sequencer = sequencer:new(4, "channel") 6 | local scale_fader = fader:new(1, 3, 16, 16) 7 | local transpose_fader = fader:new(8, 8, 9, 25) 8 | 9 | local hide_scale_fader_leds = false 10 | 11 | function scale_edit_page.init() 12 | scale_edit_page.refresh() 13 | scale_edit_page_ui.refresh() 14 | 15 | scale_fader:set_pre_func( 16 | function(x, y, length) 17 | local channel = program.get_channel(program.get().selected_song_pattern, 17) 18 | for i = x, length + x - 1 do 19 | if hide_scale_fader_leds then 20 | break 21 | end 22 | if m_clock.is_playing() and i == channel.step_scale_number then 23 | grid_abstraction.led(i, y, 15) 24 | scale_fader:set_value(0) 25 | elseif i == program.get().selected_scale then 26 | grid_abstraction.led(i, y, 4) 27 | end 28 | end 29 | end 30 | ) 31 | 32 | transpose_fader:set_value(13) 33 | end 34 | 35 | function scale_edit_page.register_draws() 36 | draw:register_grid( 37 | "scale_edit_page", 38 | function() 39 | scale_edit_page_sequencer:draw(program.get_channel(program.get().selected_song_pattern, 17), grid_abstraction.led) 40 | end 41 | ) 42 | draw:register_grid( 43 | "scale_edit_page", 44 | function() 45 | scale_fader:draw() 46 | end 47 | ) 48 | draw:register_grid( 49 | "scale_edit_page", 50 | function() 51 | transpose_fader:draw() 52 | end 53 | ) 54 | end 55 | 56 | function scale_edit_page.register_press() 57 | press:register_dual( 58 | "scale_edit_page", 59 | function(x, y, x2, y2) 60 | local channel = program.get_channel(program.get().selected_song_pattern, 17) 61 | local selected_song_pattern = program.get().selected_song_pattern 62 | scale_edit_page_sequencer:dual_press(x, y, x2, y2) 63 | if scale_edit_page_sequencer:is_this(x2, y2) then 64 | program.get_selected_song_pattern().active = true 65 | tooltip:show("Channel 17 length changed") 66 | end 67 | if scale_fader:is_this(x2, y2) then 68 | scale_fader:press(x2, y2) 69 | local s = fn.calc_grid_count(x, y) 70 | local scale_value = scale_fader:get_value() 71 | if scale_value == program.get_step_scale_trig_lock(channel, s) then 72 | program.add_step_scale_trig_lock(s, nil) 73 | else 74 | program.add_step_scale_trig_lock(s, scale_value) 75 | end 76 | scale_edit_page.refresh_faders() 77 | end 78 | if transpose_fader:is_this(x2, y2) then 79 | transpose_fader:press(x2, y2) 80 | local s = fn.calc_grid_count(x, y) 81 | local transpose_value = transpose_fader:get_value() - 13 82 | if transpose_value == program.get_step_transpose_trig_lock(s) then 83 | program.add_step_transpose_trig_lock(s, nil) 84 | else 85 | program.add_step_transpose_trig_lock(s, transpose_value) 86 | end 87 | scale_edit_page.refresh_faders() 88 | end 89 | end 90 | ) 91 | press:register( 92 | "scale_edit_page", 93 | function(x, y) 94 | if scale_fader:is_this(x, y) then 95 | if is_key1_down then 96 | program.get().selected_scale = x 97 | scale_edit_page_ui.refresh() 98 | else 99 | scale_fader:press(x, y) 100 | local scale_value = scale_fader:get_value() 101 | local number = program.get_scale(scale_value).number 102 | program.get().selected_scale = x 103 | if program.get().default_scale ~= scale_value then 104 | program.get().default_scale = scale_value 105 | tooltip:show("Global scale: " .. quantiser.get_notes()[program.get_scale(scale_value).root_note + 1] .. " " .. quantiser.get_scale_name_from_index(number)) 106 | else 107 | program.get().default_scale = 0 108 | scale_fader:set_value(0) 109 | tooltip:show("Global scale off") 110 | end 111 | scale_edit_page_ui.refresh_quantiser() 112 | end 113 | end 114 | end 115 | ) 116 | press:register_long( 117 | "scale_edit_page", 118 | function(x, y) 119 | if scale_fader:is_this(x, y) then 120 | if program.get().selected_scale == x then 121 | program.get().selected_scale = 0 122 | scale_fader:set_value(0) 123 | program.get().default_scale = 0 124 | tooltip:show("Global scale off") 125 | else 126 | program.get().selected_scale = x 127 | end 128 | scale_edit_page_ui.refresh() 129 | end 130 | end 131 | ) 132 | press:register_pre( 133 | "scale_edit_page", 134 | function(x, y) 135 | if scale_edit_page_sequencer:is_this(x, y) then 136 | scale_edit_page.refresh_faders() 137 | end 138 | end 139 | ) 140 | press:register_post( 141 | "scale_edit_page", 142 | function(x, y) 143 | if scale_edit_page_sequencer:is_this(x, y) then 144 | scale_edit_page.refresh_faders() 145 | end 146 | end 147 | ) 148 | press:register( 149 | "scale_edit_page", 150 | function(x, y) 151 | if transpose_fader:is_this(x, y) then 152 | transpose_fader:press(x, y) 153 | program.set_transpose(transpose_fader:get_value() - 13) 154 | program.get_selected_song_pattern().active = true 155 | tooltip:show("Transpose: " .. transpose_fader:get_value() - 13) 156 | end 157 | end 158 | ) 159 | end 160 | 161 | 162 | function scale_edit_page.refresh_faders() 163 | local channel = program.get_channel(program.get().selected_song_pattern, 17) 164 | local pressed_keys = m_grid.get_pressed_keys() 165 | if #pressed_keys > 0 then 166 | local s = fn.calc_grid_count(pressed_keys[1][1], pressed_keys[1][2]) 167 | local step_scale_trig_lock = program.get_step_scale_trig_lock(channel, s) 168 | local step_transpose_trig_lock = program.get_step_transpose_trig_lock(s) 169 | if step_scale_trig_lock then 170 | scale_fader:set_value(step_scale_trig_lock) 171 | hide_scale_fader_leds = true 172 | else 173 | if program.get().default_scale > 0 then 174 | scale_fader:set_value(program.get().default_scale) 175 | else 176 | scale_fader:set_value(0) 177 | end 178 | end 179 | if step_transpose_trig_lock then 180 | transpose_fader:set_value(step_transpose_trig_lock + 13) 181 | else 182 | transpose_fader:set_value(nil) 183 | end 184 | else 185 | scale_fader:set_value(program.get().default_scale) 186 | transpose_fader:set_value(program.get_transpose() + 13) 187 | hide_scale_fader_leds = false 188 | end 189 | end 190 | 191 | scale_edit_page.refresh = scheduler.debounce(function() 192 | scale_edit_page.refresh_faders() 193 | end, 0.01) 194 | 195 | return scale_edit_page 196 | -------------------------------------------------------------------------------- /lib/controls/sequencer.lua: -------------------------------------------------------------------------------- 1 | sequencer = {} 2 | sequencer.__index = sequencer 3 | 4 | 5 | local setmetatable = setmetatable 6 | local clock_run = clock.run 7 | local clock_sleep = clock.sleep 8 | local math_min = math.min 9 | 10 | function sequencer:new(y, mode) 11 | local self = setmetatable({}, self) 12 | self.y = y 13 | self.unsaved_grid = {} 14 | self.mode = mode == "channel" and "channel" or "pattern" 15 | 16 | return self 17 | end 18 | 19 | function sequencer:draw(channel, draw_func) 20 | 21 | local bright_mod = 0 22 | 23 | if program.get_blink_state() then 24 | bright_mod = 0 25 | else 26 | bright_mod = 3 27 | end 28 | 29 | local mode = self.mode 30 | local bright_mod_15 = 15 - bright_mod 31 | local bright_mod_2 = 2 - ((bright_mod == 3 and 1) or (bright_mod == 0 and 0) or bright_mod) 32 | local unsaved_grid = self.unsaved_grid 33 | 34 | local trigs = channel.working_pattern.trig_values 35 | local lengths = channel.working_pattern.lengths 36 | 37 | local selected_pattern = program.get_selected_pattern() 38 | local program_get_selected_song_pattern = program.get_selected_song_pattern 39 | local program_get_current_step_for_channel = program.get_current_step_for_channel 40 | local program_step_has_trig_lock = channel_edit_page_ui.should_show_step_has_trig_lock 41 | 42 | local m_clock_is_playing = m_clock.is_playing 43 | local fn_calc_grid_count = fn.calc_grid_count 44 | local math_floor = math.floor 45 | 46 | if mode == "pattern" then 47 | trigs = selected_pattern.trig_values 48 | lengths = selected_pattern.lengths 49 | end 50 | 51 | local length = -1 52 | local grid_count = -1 53 | 54 | local start_x = channel.start_trig[1] 55 | local start_y = channel.start_trig[2] 56 | local start_step = fn_calc_grid_count(start_x, start_y) 57 | 58 | local end_x = channel.end_trig[1] 59 | local end_y = channel.end_trig[2] 60 | local end_step = fn_calc_grid_count(end_x, end_y) 61 | local global_pattern_length = program_get_selected_song_pattern().global_pattern_length 62 | 63 | if global_pattern_length < end_step then 64 | if start_step == 1 then 65 | end_step = global_pattern_length 66 | else 67 | end_step = start_step + math_min(end_step - start_step, global_pattern_length - 1) 68 | end 69 | end 70 | 71 | local current_step = program_get_current_step_for_channel(channel.number) 72 | 73 | for y = self.y, self.y + 3 do 74 | for x = 1, 16 do 75 | local grid_count = fn_calc_grid_count(x, y) 76 | local in_step_length = start_step <= grid_count and end_step >= grid_count 77 | 78 | if mode == "channel" then 79 | if in_step_length then 80 | if program_step_has_trig_lock(channel, grid_count) then 81 | draw_func(x, y, bright_mod_2) 82 | else 83 | draw_func(x, y, 2) 84 | end 85 | end 86 | else 87 | draw_func(x, y, 2) 88 | end 89 | end 90 | end 91 | 92 | for y = self.y, self.y + 3 do 93 | for x = 1, 16 do 94 | local grid_count = fn_calc_grid_count(x, y) 95 | local in_step_length = start_step <= grid_count and end_step >= grid_count 96 | 97 | if unsaved_grid[grid_count] then 98 | draw_func(x, y, bright_mod_15) 99 | end 100 | 101 | if trigs[grid_count] > 0 then 102 | if mode == "channel" then 103 | if in_step_length then 104 | if program_step_has_trig_lock(channel, grid_count) then 105 | draw_func(x, y, bright_mod_15) 106 | else 107 | draw_func(x, y, 15) 108 | end 109 | end 110 | else 111 | draw_func(x, y, 15) 112 | end 113 | 114 | if unsaved_grid[grid_count] then 115 | draw_func(x, y, bright_mod) 116 | end 117 | 118 | length = lengths[grid_count] 119 | 120 | if length > 1 then 121 | 122 | for lx = grid_count + 1, grid_count + length - 1 do 123 | if lx > 64 then 124 | lx = lx - 64 125 | end 126 | 127 | if trigs[lx] < 1 and lx < 65 then 128 | local lx_x = ((lx - 1) % 16) + 1 129 | local lx_y = self.y + math_floor((lx - 1) / 16) 130 | local length_grid_count = fn_calc_grid_count(lx_x, lx_y) 131 | if not (mode == "channel" and not (end_step >= length_grid_count and in_step_length)) and (start_step <= length_grid_count) then 132 | if program_step_has_trig_lock(channel, lx) then 133 | draw_func(lx_x, lx_y, 5 - ((bright_mod == 3 and 1) or (bright_mod == 0 and 0) or bright_mod)) 134 | else 135 | draw_func(lx_x, lx_y, 5) 136 | end 137 | end 138 | else 139 | break 140 | end 141 | end 142 | end 143 | end 144 | 145 | if current_step == grid_count and m_clock_is_playing() then 146 | if mode == "channel" then 147 | if grid_count >= start_step then 148 | if program_step_has_trig_lock(channel, grid_count) then 149 | draw_func(x, y, 10 - bright_mod) 150 | else 151 | draw_func(x, y, 10) 152 | end 153 | end 154 | end 155 | end 156 | end 157 | end 158 | end 159 | 160 | function sequencer:press(x, y) 161 | if y >= self.y and y <= self.y + 3 then 162 | if self.mode == "pattern" then 163 | local grid_count = fn.calc_grid_count(x, y) 164 | local selected_pattern = program.get_selected_pattern() 165 | selected_pattern.trig_values[grid_count] = 1 - selected_pattern.trig_values[grid_count] 166 | program.get_selected_song_pattern().active = true 167 | end 168 | end 169 | end 170 | 171 | function sequencer:dual_press(x, y, x2, y2) 172 | if y >= self.y and y <= self.y + 3 and y2 >= self.y and y2 <= self.y + 3 then 173 | if self.mode == "channel" then 174 | program.get_selected_channel().start_trig = {x, y} 175 | program.get_selected_channel().end_trig = {x2, y2} 176 | elseif self.mode == "pattern" then 177 | local grid_count = fn.calc_grid_count(x, y) 178 | if program.get_selected_pattern().trig_values[grid_count] == 1 then 179 | local length = fn.calc_grid_count(x2, y2) - grid_count 180 | if length > 0 then 181 | program.get_selected_pattern().lengths[grid_count] = length + 1 182 | else 183 | program.get_selected_pattern().lengths[grid_count] = (64 - grid_count) + fn.calc_grid_count(x2, y2) + 1 184 | end 185 | end 186 | end 187 | end 188 | end 189 | 190 | function sequencer:long_press(x, y) 191 | if y >= self.y and y <= self.y + 3 then 192 | if self.mode == "pattern" then 193 | local grid_count = fn.calc_grid_count(x, y) 194 | if program.get_selected_pattern().trig_values[grid_count] == 1 then 195 | program.get_selected_pattern().lengths[grid_count] = 1 196 | end 197 | end 198 | end 199 | end 200 | 201 | function sequencer:is_this(x, y) 202 | return y >= self.y and y <= self.y + 3 203 | end 204 | 205 | function sequencer:show_unsaved_grid(g) 206 | self.unsaved_grid = g 207 | end 208 | 209 | function sequencer:hide_unsaved_grid() 210 | self.unsaved_grid = {} 211 | end 212 | 213 | return sequencer 214 | -------------------------------------------------------------------------------- /lib/pages/song_edit_page/song_edit_page.lua: -------------------------------------------------------------------------------- 1 | local song_edit_page = {} 2 | local channel_pattern_buttons = {} 3 | 4 | 5 | local refresh_button = {} 6 | 7 | local global_pattern_length_fader = fader:new(1, 7, 8, 64) 8 | 9 | function song_edit_page.init() 10 | for s = 1, 96 do 11 | channel_pattern_buttons["step" .. s .. "_song_pattern_button"] = 12 | button:new( 13 | (s - 1) % 16 + 1, 14 | math.floor((s - 1) / 16) + 1, 15 | { 16 | {"Song sequence " .. s .. " off", 2}, 17 | {"Song sequence " .. s .. " on", 7}, 18 | {"Song sequence " .. s .. " active", 15} 19 | } 20 | ) 21 | refresh_button[s] = true 22 | end 23 | 24 | global_pattern_length_fader:set_value(program.get_selected_song_pattern().global_pattern_length) 25 | 26 | channel_pattern_buttons["step" .. program.get().selected_song_pattern .. "_song_pattern_button"]:set_state( 27 | 3 28 | ) 29 | end 30 | 31 | function song_edit_page.register_draws() 32 | draw:register_grid( 33 | "song_edit_page", 34 | function() 35 | local song_pattern = program.get().selected_song_pattern 36 | for s = 1, 96 do 37 | if refresh_button[s] then 38 | if song_pattern == s then 39 | channel_pattern_buttons["step" .. s .. "_song_pattern_button"]:set_state(3) 40 | elseif program.is_song_pattern_active(s) then 41 | channel_pattern_buttons["step" .. s .. "_song_pattern_button"]:set_state(2) 42 | else 43 | channel_pattern_buttons["step" .. s .. "_song_pattern_button"]:set_state(1) 44 | end 45 | refresh_button[s] = false 46 | end 47 | channel_pattern_buttons["step" .. s .. "_song_pattern_button"]:draw() 48 | end 49 | end 50 | ) 51 | 52 | draw:register_grid( 53 | "song_edit_page", 54 | function() 55 | global_pattern_length_fader:draw() 56 | end 57 | ) 58 | end 59 | 60 | function song_edit_page.register_press() 61 | press:register( 62 | "song_edit_page", 63 | function(x, y) 64 | local s = fn.calc_grid_count(x, y) + 48 65 | local previous_selected_pattern = program.get().selected_song_pattern 66 | if 67 | channel_pattern_buttons["step" .. s .. "_song_pattern_button"] and 68 | channel_pattern_buttons["step" .. s .. "_song_pattern_button"]:is_this(x, y) 69 | then 70 | local do_func = function() 71 | channel_pattern_buttons["step" .. s .. "_song_pattern_button"]:set_state(3) 72 | program.get().selected_song_pattern = s 73 | tooltip:show("Song sequence " .. s .. " selected") 74 | 75 | for channel_number = 1, 17 do 76 | local channel = program.get_channel(program.get().selected_song_pattern, channel_number) 77 | m_clock.set_channel_division(channel_number, m_clock.calculate_divisor(channel.clock_mods)) 78 | m_clock.get_clock_lattice().global_pattern_length = program.get_selected_song_pattern().global_pattern_length 79 | if channel_number ~= 17 then 80 | channel_edit_page_ui.align_global_and_local_shuffle_feel_values(channel_number) 81 | channel_edit_page_ui.align_global_and_local_swing_values(channel_number) 82 | channel_edit_page_ui.align_global_and_local_swing_shuffle_type_values(channel_number) 83 | channel_edit_page_ui.align_global_and_local_shuffle_basis_values(channel_number) 84 | channel_edit_page_ui.align_global_and_local_shuffle_amount_values(channel_number) 85 | end 86 | 87 | end 88 | 89 | if program.is_song_pattern_active(previous_selected_pattern) then 90 | channel_pattern_buttons["step" .. s .. "_song_pattern_button"]:set_state(2) 91 | else 92 | channel_pattern_buttons["step" .. s .. "_song_pattern_button"]:set_state(1) 93 | end 94 | 95 | refresh_button[previous_selected_pattern] = true 96 | refresh_button[s] = true 97 | 98 | pattern.update_working_patterns() 99 | 100 | song_edit_page.refresh() 101 | song_edit_page.refresh_faders() 102 | channel_edit_page_ui.refresh_clock_mods() 103 | channel_edit_page_ui.refresh_swing() 104 | channel_edit_page_ui.refresh_swing_shuffle_type() 105 | channel_edit_page_ui.refresh_shuffle_feel() 106 | channel_edit_page_ui.refresh_shuffle_basis() 107 | channel_edit_page_ui.refresh_shuffle_amount() 108 | 109 | end 110 | 111 | local blink_cancel_func = function() 112 | channel_pattern_buttons["step" .. s .. "_song_pattern_button"]:no_blink() 113 | end 114 | 115 | if m_clock.is_playing() then 116 | step.execute_blink_cancel_func() 117 | step.queue_switch_to_next_song_pattern_func(do_func) 118 | step.queue_switch_to_next_song_pattern_blink_cancel_func(blink_cancel_func) 119 | step.queue_next_song_pattern(s) 120 | 121 | channel_pattern_buttons["step" .. s .. "_song_pattern_button"]:blink() 122 | else 123 | if params:get("elektron_program_changes") == 2 then 124 | step.process_elektron_program_change(s) 125 | end 126 | do_func() 127 | end 128 | 129 | end 130 | end 131 | ) 132 | press:register( 133 | "song_edit_page", 134 | function(x, y) 135 | if global_pattern_length_fader:is_this(x, y) then 136 | global_pattern_length_fader:press(x, y) 137 | 138 | local song_pattern = program.get().selected_song_pattern 139 | local new_pattern_length = global_pattern_length_fader:get_value() 140 | if m_clock.is_playing() then 141 | tooltip:show("Q'd: Global pattern length: " .. new_pattern_length) 142 | step.queue_for_pattern_change(function() 143 | program.get_song_pattern(song_pattern).global_pattern_length = new_pattern_length 144 | m_clock.get_clock_lattice().pattern_length = new_pattern_length 145 | program.get().global_step_accumulator = 0 146 | end) 147 | else 148 | program.get_song_pattern(song_pattern).global_pattern_length = new_pattern_length 149 | m_clock.get_clock_lattice().pattern_length = new_pattern_length 150 | tooltip:show("Global pattern length: " .. new_pattern_length) 151 | end 152 | end 153 | end 154 | ) 155 | press:register_dual( 156 | "song_edit_page", 157 | function(x, y, x2, y2) 158 | local pattern = fn.calc_grid_count(x, y) + 48 159 | local target_pattern = fn.calc_grid_count(x2, y2) + 48 160 | if 161 | channel_pattern_buttons["step" .. pattern .. "_song_pattern_button"] and 162 | channel_pattern_buttons["step" .. pattern .. "_song_pattern_button"]:is_this(x, y) and 163 | channel_pattern_buttons["step" .. target_pattern .. "_song_pattern_button"] and 164 | channel_pattern_buttons["step" .. target_pattern .. "_song_pattern_button"]:is_this(x2, y2) 165 | then 166 | program.set_song_pattern(pattern, target_pattern) 167 | refresh_button[pattern] = true 168 | refresh_button[target_pattern] = true 169 | end 170 | song_edit_page.refresh() 171 | end 172 | ) 173 | end 174 | 175 | function song_edit_page.refresh_faders() 176 | global_pattern_length_fader:set_value(program.get_selected_song_pattern().global_pattern_length) 177 | end 178 | 179 | function song_edit_page.refresh() 180 | song_edit_page_ui.refresh() 181 | 182 | refresh_button = { 183 | true, true, true, true, true, true, true, true, true, true, 184 | true, true, true, true, true, true, true, true, true, true, 185 | true, true, true, true, true, true, true, true, true, true, 186 | true, true, true, true, true, true, true, true, true, true, 187 | true, true, true, true, true, true, true, true, true, true, 188 | true, true, true, true, true, true, true, true, true, true, 189 | true, true, true, true, true, true, true, true, true, true, 190 | true, true, true, true, true, true, true, true, true, true, 191 | true, true, true, true, true, true, true, true, true, true, 192 | true, true, true, true, true, true 193 | } 194 | 195 | song_edit_page.refresh_faders() 196 | end 197 | 198 | return song_edit_page 199 | -------------------------------------------------------------------------------- /lib/ui_components/dial.lua: -------------------------------------------------------------------------------- 1 | local dial = {} 2 | dial.__index = dial 3 | 4 | function dial:new(x, y, name, id, top_label, bottom_label) 5 | local self = setmetatable({}, dial) 6 | self.x = x 7 | self.y = y 8 | self.name = name 9 | self.value = -1 10 | self.top_label = top_label 11 | self.bottom_label = bottom_label 12 | self.selected = false 13 | self.min_value = nil 14 | self.max_value = nil 15 | self.off_value = -1 16 | self.ui_labels = nil 17 | self.display_value = false 18 | self.display_value_clock = nil 19 | self.display_modifier = function() end 20 | 21 | return self 22 | end 23 | 24 | function dial:draw() 25 | -- Set screen level based on selection 26 | screen.level(self.selected and 15 or 1) 27 | 28 | self.display_modifier(self.x, self.y) 29 | 30 | screen.move(self.x, self.y) 31 | 32 | screen.text_trim(fn.title_case(self.top_label), 24) 33 | 34 | -- Position for drawing the bar 35 | local bar_x = self.x 36 | local bar_y = self.y + 7 -- Position below the top label 37 | 38 | -- Ensure `self.value` is within `min_value` and `max_value` 39 | if self.min_value and self.value then 40 | self.value = math.max(self.min_value, math.min(self.value, self.max_value)) 41 | end 42 | 43 | -- Handle special cases for displaying value 44 | if self.value == self.off_value or not self.value or not self.min_value or not self.max_value then 45 | screen.move(self.x, bar_y) 46 | screen.text("X") 47 | elseif self.ui_labels and self.min_value then 48 | screen.move(self.x, bar_y) 49 | screen.text(self.ui_labels[self.value - (self.min_value - 1)] or "", 24) 50 | elseif self.display_value == true then 51 | screen.move(self.x, bar_y) 52 | screen.text(self.value and fn.clean_number(self.value) or "X") 53 | else 54 | -- Define bar dimensions and segments 55 | local bar_width = 19 -- Total width of the bar 56 | local bar_height = 4 57 | local num_segments = 20 58 | local segment_width = bar_width / num_segments 59 | local center_x = bar_x + (bar_width / 2) 60 | 61 | -- Determine if the range includes negative values 62 | local off_value = self.off_value or 0 -- Default to 0 if not set 63 | local min_value = self.min_value or 0 -- Default to 0 if not set 64 | local is_negative_range = min_value < off_value 65 | 66 | if is_negative_range then 67 | -- Negative and positive values 68 | local total_negative_range = off_value - min_value 69 | local total_positive_range = self.max_value - off_value 70 | local half_num_segments = num_segments / 2 71 | 72 | if self.value >= off_value then 73 | -- Positive values: fill from center to right 74 | local value_fraction = (self.value - off_value) / total_positive_range 75 | if total_positive_range == 0 then value_fraction = 0 end -- Prevent division by zero 76 | local filled_segments = math.floor(value_fraction * half_num_segments) 77 | local partial_fill = (value_fraction * half_num_segments) - filled_segments 78 | 79 | -- Draw filled segments 80 | for i = 1, filled_segments do 81 | local segment_x = center_x + (i - 1) * segment_width 82 | screen.rect(segment_x, bar_y - bar_height, segment_width, bar_height) 83 | screen.fill() 84 | end 85 | 86 | -- Draw partial segment 87 | if partial_fill > 0 and filled_segments < half_num_segments then 88 | local segment_x = center_x + filled_segments * segment_width 89 | local fill_width = segment_width * partial_fill 90 | screen.rect(segment_x, bar_y - bar_height, fill_width, bar_height) 91 | screen.fill() 92 | end 93 | else 94 | -- Negative values: fill from center to left 95 | local value_fraction = (off_value - self.value) / total_negative_range 96 | if total_negative_range == 0 then value_fraction = 0 end -- Prevent division by zero 97 | local filled_segments = math.floor(value_fraction * half_num_segments) 98 | local partial_fill = (value_fraction * half_num_segments) - filled_segments 99 | 100 | -- Draw filled segments 101 | for i = 1, filled_segments do 102 | local segment_x = center_x - i * segment_width 103 | screen.rect(segment_x, bar_y - bar_height, segment_width, bar_height) 104 | screen.fill() 105 | end 106 | 107 | -- Draw partial segment 108 | if partial_fill > 0 and filled_segments < half_num_segments then 109 | local segment_x = center_x - (filled_segments + 1) * segment_width 110 | local fill_width = segment_width * partial_fill 111 | screen.rect(segment_x + (segment_width - fill_width), bar_y - bar_height, fill_width, bar_height) 112 | screen.fill() 113 | end 114 | end 115 | else 116 | -- Positive-only values: fill from left edge to right 117 | local total_range = self.max_value - self.min_value 118 | local value_fraction = (self.value - self.min_value) / total_range 119 | if total_range == 0 then value_fraction = 0 end -- Prevent division by zero 120 | local filled_segments = math.floor(value_fraction * num_segments) 121 | local partial_fill = (value_fraction * num_segments) - filled_segments 122 | 123 | -- Draw filled segments 124 | for i = 1, filled_segments do 125 | local segment_x = bar_x + (i - 1) * segment_width 126 | screen.rect(segment_x, bar_y - bar_height, segment_width, bar_height) 127 | screen.fill() 128 | end 129 | 130 | -- Draw partial segment 131 | if partial_fill > 0 and filled_segments < num_segments then 132 | local segment_x = bar_x + filled_segments * segment_width 133 | local fill_width = segment_width * partial_fill 134 | screen.rect(segment_x, bar_y - bar_height, fill_width, bar_height) 135 | screen.fill() 136 | end 137 | end 138 | end 139 | 140 | 141 | screen.move(self.x, self.y + 14) 142 | 143 | screen.text(fn.title_case(self.bottom_label)) 144 | end 145 | 146 | 147 | function dial:select() 148 | self.selected = true 149 | fn.dirty_screen(true) 150 | end 151 | 152 | function dial:deselect() 153 | self.selected = false 154 | fn.dirty_screen(true) 155 | end 156 | 157 | function dial:is_selected() 158 | return self.selected 159 | end 160 | 161 | function dial:increment() 162 | self.value = self.value + 1 163 | fn.dirty_screen(true) 164 | end 165 | 166 | function dial:decrement() 167 | self.value = self.value - 1 168 | fn.dirty_screen(true) 169 | end 170 | 171 | function dial:set_value(value) 172 | local epsilon = 1e-10 173 | if value == nil or (self.min_value and value < (self.min_value - epsilon)) or (self.max_value and value > (self.max_value + epsilon)) then 174 | value = self.off_value 175 | end 176 | 177 | self.value = value 178 | fn.dirty_screen(true) 179 | end 180 | 181 | function dial:get_value() 182 | return self.value 183 | end 184 | 185 | function dial:set_top_label(label) 186 | self.top_label = label 187 | fn.dirty_screen(true) 188 | end 189 | 190 | function dial:set_bottom_label(label) 191 | self.bottom_label = label 192 | fn.dirty_screen(true) 193 | end 194 | 195 | function dial:set_name() 196 | self.name = name 197 | fn.dirty_screen(true) 198 | end 199 | 200 | function dial:get_name() 201 | return self.name 202 | end 203 | 204 | function dial:get_id() 205 | return self.id 206 | end 207 | 208 | function dial:set_off_value(off_value) 209 | self.off_value = off_value 210 | end 211 | 212 | function dial:set_ui_labels(ui_labels) 213 | self.ui_labels = ui_labels 214 | end 215 | 216 | function dial:set_min_value(min_value) 217 | self.min_value = min_value 218 | end 219 | 220 | function dial:set_max_value(max_value) 221 | self.max_value = max_value 222 | end 223 | 224 | function dial:temp_display_value() 225 | self.display_value = true 226 | 227 | if self.display_value_clock then 228 | clock.cancel(self.display_value_clock) 229 | end 230 | 231 | self.display_value_clock = clock.run(function() 232 | clock.sleep(2) 233 | self.display_value = false 234 | clock.cancel(self.display_value_clock) 235 | self.display_value_clock = nil 236 | end) 237 | end 238 | 239 | function dial:set_display_modifier(display_modifier) 240 | self.display_modifier = display_modifier 241 | end 242 | 243 | return dial 244 | -------------------------------------------------------------------------------- /lib/pages/channel_edit_page/channel_edit_page_ui_refreshers.lua: -------------------------------------------------------------------------------- 1 | -- channel_edit_page_ui_refreshers.lua 2 | local channel_edit_page_ui_refreshers = {} 3 | local quantiser = include("lib/quantiser") 4 | local divisions = include("lib/clock/divisions") 5 | 6 | local throttle_time = 0.01 7 | 8 | channel_edit_page_ui_refreshers.refresh_masks = scheduler.debounce(function(note_selectors) 9 | local pressed_keys = m_grid.get_pressed_keys() 10 | local channel = program.get_selected_channel() 11 | 12 | -- Cache frequently accessed selectors 13 | local note_selector = note_selectors.note 14 | local velocity_selector = note_selectors.velocity 15 | local length_selector = note_selectors.length 16 | local trig_selector = note_selectors.trig 17 | local chord_selectors = note_selectors.chords 18 | 19 | -- Cache channel properties 20 | local note_mask = channel.note_mask or -1 21 | local velocity_mask = channel.velocity_mask or -1 22 | local length_mask_index = divisions.note_division_indexes[channel.length_mask] or 0 23 | local trig_mask = channel.trig_mask or -1 24 | local chord_masks = { 25 | channel.chord_one_mask or 0, 26 | channel.chord_two_mask or 0, 27 | channel.chord_three_mask or 0, 28 | channel.chord_four_mask or 0 29 | } 30 | 31 | if #pressed_keys > 0 and pressed_keys[1][2] > 3 and pressed_keys[1][2] < 8 then 32 | -- Cache step masks for efficiency 33 | local step_note_masks = channel.step_note_masks 34 | local step_velocity_masks = channel.step_velocity_masks 35 | local step_length_masks = channel.step_length_masks 36 | local step_trig_masks = channel.step_trig_masks 37 | local step_chord_masks = channel.step_chord_masks 38 | 39 | for _, keys in ipairs(pressed_keys) do 40 | local s = fn.calc_grid_count(keys[1], keys[2]) 41 | 42 | -- Set note selector values using cached masks 43 | note_selector:set_value(step_note_masks[s] or note_mask) 44 | velocity_selector:set_value(step_velocity_masks[s] or velocity_mask) 45 | length_selector:set_value(divisions.note_division_indexes[step_length_masks[s]] or length_mask_index) 46 | trig_selector:set_value(step_trig_masks[s] or trig_mask) 47 | 48 | -- Get step chord masks or default chord masks 49 | local step_chords = step_chord_masks[s] or chord_masks 50 | 51 | for i, chord_selector in ipairs(chord_selectors) do 52 | chord_selector:set_value(step_chords[i] or chord_masks[i]) 53 | end 54 | end 55 | else 56 | -- Set selectors to channel-level masks 57 | note_selector:set_value(note_mask) 58 | velocity_selector:set_value(velocity_mask) 59 | length_selector:set_value(length_mask_index) 60 | trig_selector:set_value(trig_mask) 61 | 62 | for i, chord_selector in ipairs(chord_selectors) do 63 | chord_selector:set_value(chord_masks[i]) 64 | end 65 | end 66 | end, throttle_time) 67 | 68 | channel_edit_page_ui_refreshers.refresh_clock_mods = scheduler.debounce(function(clock_mod_list_selector, clock_swing_value_selector) 69 | local channel = program.get_selected_channel() 70 | local clock_mods = channel.clock_mods 71 | local divisions = fn.filter_by_type(m_clock.get_clock_divisions(), clock_mods.type) 72 | local i = fn.find_index_in_table_by_value(divisions, clock_mods.value) 73 | if clock_mods.type == "clock_division" then 74 | i = i + 12 75 | end 76 | clock_mod_list_selector:set_selected_value(i) 77 | end, throttle_time) 78 | 79 | channel_edit_page_ui_refreshers.refresh_swing = scheduler.debounce(function(clock_swing_value_selector) 80 | local channel = program.get_selected_channel() 81 | local value = channel.swing 82 | if value == nil then 83 | value = -51 84 | end 85 | clock_swing_value_selector:set_value(value) 86 | end, throttle_time) 87 | 88 | channel_edit_page_ui_refreshers.refresh_swing_shuffle_type = scheduler.debounce(function(swing_shuffle_type_selector) 89 | local channel = program.get_selected_channel() 90 | local value = channel.swing_shuffle_type or 0 91 | channel_edit_page_ui.set_swing_shuffle_type_selector_value(value) 92 | end, throttle_time) 93 | 94 | 95 | channel_edit_page_ui_refreshers.refresh_shuffle_feel = scheduler.debounce(function(shuffle_feel_selector) 96 | local channel = program.get_selected_channel() 97 | local value = channel.shuffle_feel or 0 98 | channel_edit_page_ui.set_shuffle_feel_selector_value(value) 99 | end, throttle_time) 100 | 101 | channel_edit_page_ui_refreshers.refresh_shuffle_basis = scheduler.debounce(function(shuffle_basis_selector) 102 | local channel = program.get_selected_channel() 103 | local value = channel.shuffle_basis or 0 104 | channel_edit_page_ui.set_shuffle_basis_selector_value(value) 105 | end, throttle_time) 106 | 107 | channel_edit_page_ui_refreshers.refresh_shuffle_amount = scheduler.debounce(function(shuffle_amount_selector) 108 | local channel = program.get_selected_channel() 109 | local value = channel.shuffle_amount or 0 110 | shuffle_amount_selector:set_value(value) 111 | end, throttle_time) 112 | 113 | channel_edit_page_ui_refreshers.refresh_device_selector = scheduler.debounce(function(device_map_vertical_scroll_selector, param_select_vertical_scroll_selector) 114 | local channel = program.get_selected_channel() 115 | local device = device_map.get_device(program.get().devices[channel.number].device_map) 116 | local device_params = device_map.get_params(program.get().devices[channel.number].device_map) 117 | param_select_vertical_scroll_selector:set_items(device_params) 118 | param_select_vertical_scroll_selector:set_meta_item(device) 119 | end, throttle_time) 120 | 121 | channel_edit_page_ui_refreshers.refresh_trig_lock_value = scheduler.debounce(function(i, m_params) 122 | local channel = program.get_selected_channel() 123 | local param_id = channel.trig_lock_params[i].param_id 124 | 125 | if not param_id then 126 | m_params[i]:set_value(-1) 127 | return 128 | end 129 | 130 | local val = fn.clean_number(params:get(param_id)) 131 | 132 | if (norns_param_state_handler.get_original_param_state(channel.number, i).value) then 133 | val = norns_param_state_handler.get_original_param_state(channel.number, i).value 134 | end 135 | 136 | m_params[i]:set_value(val) 137 | 138 | end, throttle_time) 139 | 140 | 141 | channel_edit_page_ui_refreshers.refresh_trig_locks = scheduler.debounce(function(m_params) 142 | local channel = program.get_selected_channel() 143 | local pressed_keys = m_grid.get_pressed_keys() 144 | local current_step = program.get_current_step_for_channel(channel.number) 145 | 146 | -- Process all updates in a single batch 147 | local updates = {} 148 | 149 | for i = 1, 10 do 150 | -- Gather all the updates first 151 | local m_param = m_params[i] 152 | local trig_lock_param = channel.trig_lock_params[i] 153 | if trig_lock_param and trig_lock_param.param_id then 154 | local param_id = trig_lock_param.param_id 155 | local param_value = params:get(param_id) or trig_lock_param.off_value 156 | local default_param = norns_param_state_handler.get_original_param_state(channel.number, i).value 157 | 158 | -- Store the update info 159 | updates[i] = { 160 | param = m_param, 161 | value = default_param or param_value, 162 | trig_lock_param = trig_lock_param 163 | } 164 | 165 | -- Handle pressed keys 166 | if #pressed_keys > 0 then 167 | local pressed_key = pressed_keys[1] 168 | if pressed_key[2] > 3 and pressed_key[2] < 8 then 169 | local grid_count = fn.calc_grid_count(pressed_key[1], pressed_key[2]) 170 | local step_trig_lock = program.get_step_param_trig_lock(channel, grid_count, i) 171 | updates[i].value = step_trig_lock or params:get(param_id) 172 | end 173 | end 174 | else 175 | updates[i] = { 176 | param = m_param, 177 | value = -1, 178 | trig_lock_param = { 179 | name = "", 180 | short_descriptor_1 = "None", 181 | short_descriptor_2 = "", 182 | off_value = -1, 183 | value = -1 184 | } 185 | } 186 | end 187 | end 188 | 189 | -- Apply all updates in a single pass 190 | for i, update in pairs(updates) do 191 | local m_param = update.param 192 | local u_trig_lock_param = update.trig_lock_param 193 | 194 | m_param:set_name(u_trig_lock_param.name) 195 | m_param:set_top_label(u_trig_lock_param.short_descriptor_1) 196 | m_param:set_bottom_label(u_trig_lock_param.short_descriptor_2) 197 | m_param:set_off_value(u_trig_lock_param.off_value) 198 | m_param:set_min_value(u_trig_lock_param.nrpn_min_value or u_trig_lock_param.cc_min_value) 199 | m_param:set_max_value(u_trig_lock_param.nrpn_max_value or u_trig_lock_param.cc_max_value) 200 | m_param:set_ui_labels(u_trig_lock_param.ui_labels) 201 | m_param:set_value(update.value) 202 | end 203 | 204 | -- Request a single UI refresh after all updates are complete 205 | fn.dirty_screen(true) 206 | end, throttle_time) 207 | 208 | return channel_edit_page_ui_refreshers 209 | -------------------------------------------------------------------------------- /lib/tests/lib/m_clock_performance_tests.lua: -------------------------------------------------------------------------------- 1 | local clock = os.clock 2 | 3 | step = include("mosaic/lib/step") 4 | pattern = include("mosaic/lib/pattern") 5 | 6 | local m_clock = include("mosaic/lib/clock/m_clock") 7 | local quantiser = include("mosaic/lib/quantiser") 8 | 9 | -- Mocks 10 | include("mosaic/lib/tests/helpers/mocks/sinfonion_mock") 11 | include("mosaic/lib/tests/helpers/mocks/params_mock") 12 | include("mosaic/lib/tests/helpers/mocks/m_midi_mock") 13 | include("mosaic/lib/tests/helpers/mocks/channel_edit_page_ui_mock") 14 | include("mosaic/lib/tests/helpers/mocks/device_map_mock") 15 | include("mosaic/lib/tests/helpers/mocks/norns_mock") 16 | include("mosaic/lib/tests/helpers/mocks/channel_sequence_page_mock") 17 | include("mosaic/lib/tests/helpers/mocks/channel_edit_page_mock") 18 | 19 | local function setup() 20 | program.init() 21 | globals.reset() 22 | params.reset() 23 | end 24 | 25 | local function clock_setup() 26 | m_clock.init() 27 | m_clock:start() 28 | end 29 | 30 | local function progress_clock_by_beats(b) 31 | for i = 1, (24 * b) do 32 | m_clock.get_clock_lattice():pulse() 33 | end 34 | end 35 | 36 | local function progress_clock_by_pulses(p) 37 | for i = 1, p do 38 | m_clock.get_clock_lattice():pulse() 39 | end 40 | end 41 | 42 | 43 | -- Helper to generate test data 44 | local function generate_test_actions(count) 45 | local actions = {} 46 | for i = 1, count do 47 | actions[i] = { 48 | channel_number = (i % 16) + 1, 49 | trig_lock = (i % 10) + 1, 50 | start_step = 1, 51 | end_step = 64, 52 | start_value = 0, 53 | end_value = 127, 54 | quant = 1, 55 | func = function(val) end, 56 | should_wrap = true 57 | } 58 | end 59 | return actions 60 | end 61 | 62 | -- Memory usage helper 63 | local function get_memory_usage() 64 | collectgarbage("collect") 65 | return collectgarbage("count") 66 | end 67 | 68 | -- Benchmark helper 69 | local function benchmark_operation(name, operation, iterations) 70 | local start_time = clock() 71 | local start_memory = get_memory_usage() 72 | 73 | -- Run operation multiple times 74 | for i = 1, iterations do 75 | operation() 76 | end 77 | 78 | local end_time = clock() 79 | local end_memory = get_memory_usage() 80 | 81 | return { 82 | name = name, 83 | time = end_time - start_time, 84 | memory_delta = end_memory - start_memory, 85 | iterations = iterations 86 | } 87 | end 88 | 89 | 90 | function test_massive_concurrent_automation_with_param_slides() 91 | setup() 92 | clock_setup() 93 | 94 | local start_memory = get_memory_usage() 95 | local start_time = clock() 96 | 97 | -- Configuration 98 | local channels = 16 99 | local patterns_per_channel = 4 100 | local steps_per_pattern = 64 101 | local automation_points = 32 102 | local total_runtime_pulses = 96 * 16 -- 16 bars worth 103 | 104 | -- Track performance metrics 105 | local peak_memory = start_memory 106 | local action_count = 0 107 | local processed_values = 0 108 | 109 | -- Parameter types for slides 110 | local param_types = { 111 | { name = "cutoff", min = 0, max = 127, quant = 1 }, 112 | { name = "resonance", min = 0, max = 127, quant = 1 }, 113 | { name = "attack", min = 0, max = 127, quant = 0.1 }, 114 | { name = "decay", min = 0, max = 127, quant = 0.1 }, 115 | { name = "sustain", min = 0, max = 127, quant = 1 }, 116 | { name = "release", min = 0, max = 127, quant = 0.1 }, 117 | { name = "pan", min = -63, max = 63, quant = 1 }, 118 | { name = "delay_send", min = 0, max = 127, quant = 1 }, 119 | { name = "reverb_send", min = 0, max = 127, quant = 1 }, 120 | { name = "probability", min = 0, max = 100, quant = 0.1 } 121 | } 122 | 123 | -- Create complex automation patterns for each channel 124 | for channel = 1, channels do 125 | for pattern = 1, patterns_per_channel do 126 | -- Create multiple overlapping automations per pattern 127 | for point = 1, automation_points do 128 | local start_step = math.random(1, steps_per_pattern - 8) 129 | local end_step = math.min(start_step + math.random(4, 16), steps_per_pattern) 130 | 131 | -- Create different types of automation 132 | local param = param_types[(point % #param_types) + 1] 133 | local automation_type = point % 4 134 | local start_value, end_value, quant 135 | 136 | if automation_type == 0 then 137 | -- Full range sweep 138 | start_value = param.min 139 | end_value = param.max 140 | quant = param.quant 141 | elseif automation_type == 1 then 142 | -- Fine control around center 143 | local center = (param.max + param.min) / 2 144 | start_value = center - param.quant * 5 145 | end_value = center + param.quant * 5 146 | quant = param.quant 147 | elseif automation_type == 2 then 148 | -- High to low 149 | start_value = param.max 150 | end_value = param.min 151 | quant = param.quant * 2 152 | else 153 | -- Random range with fine control 154 | start_value = param.min + math.random() * (param.max - param.min) 155 | end_value = param.min + math.random() * (param.max - param.min) 156 | quant = param.quant / 2 157 | end 158 | 159 | -- Add some overlapping slides 160 | if point % 3 == 0 then 161 | -- Create a slide that overlaps with the next automation 162 | local slide_end = math.min(end_step + math.random(2, 8), steps_per_pattern) 163 | m_clock.execute_action_across_steps_by_pulses({ 164 | channel_number = channel, 165 | trig_lock = (pattern * automation_points + point) % 10 + 1, 166 | start_step = end_step, 167 | end_step = slide_end, 168 | start_value = end_value, 169 | end_value = param.min + math.random() * (param.max - param.min), 170 | quant = param.quant, 171 | func = function(val) 172 | processed_values = processed_values + 1 173 | end, 174 | should_wrap = true 175 | }) 176 | action_count = action_count + 1 177 | end 178 | 179 | -- Create the main automation 180 | m_clock.execute_action_across_steps_by_pulses({ 181 | channel_number = channel, 182 | trig_lock = (pattern * automation_points + point) % 10 + 1, 183 | start_step = start_step, 184 | end_step = end_step, 185 | start_value = start_value, 186 | end_value = end_value, 187 | quant = quant, 188 | func = function(val) 189 | processed_values = processed_values + 1 190 | end, 191 | should_wrap = true 192 | }) 193 | 194 | action_count = action_count + 1 195 | 196 | -- Add some wrapping slides 197 | if point % 5 == 0 and end_step > steps_per_pattern - 4 then 198 | -- Create a slide that wraps around to the start 199 | m_clock.execute_action_across_steps_by_pulses({ 200 | channel_number = channel, 201 | trig_lock = (pattern * automation_points + point) % 10 + 1, 202 | start_step = end_step, 203 | end_step = 4, 204 | start_value = end_value, 205 | end_value = param.min + math.random() * (param.max - param.min), 206 | quant = param.quant, 207 | func = function(val) 208 | processed_values = processed_values + 1 209 | end, 210 | should_wrap = true 211 | }) 212 | action_count = action_count + 1 213 | end 214 | end 215 | end 216 | end 217 | 218 | local setup_time = clock() - start_time 219 | 220 | -- Process the automation 221 | local process_start = clock() 222 | local last_memory = get_memory_usage() 223 | local memory_samples = {} 224 | 225 | -- Track timing consistency 226 | local processing_times = {} 227 | local max_process_time = 0 228 | local min_process_time = math.huge 229 | 230 | -- Run for 16 bars worth of pulses 231 | for pulse = 1, total_runtime_pulses do 232 | local pulse_start = clock() 233 | progress_clock_by_pulses(1) 234 | local pulse_time = clock() - pulse_start 235 | 236 | table.insert(processing_times, pulse_time) 237 | max_process_time = math.max(max_process_time, pulse_time) 238 | min_process_time = math.min(min_process_time, pulse_time) 239 | 240 | -- Sample memory every bar 241 | if pulse % 96 == 0 then 242 | collectgarbage("collect") 243 | local current_memory = get_memory_usage() 244 | peak_memory = math.max(peak_memory, current_memory) 245 | table.insert(memory_samples, current_memory) 246 | last_memory = current_memory 247 | end 248 | end 249 | 250 | -- Calculate timing statistics 251 | local avg_process_time = 0 252 | local timing_variance = 0 253 | for _, time in ipairs(processing_times) do 254 | avg_process_time = avg_process_time + time 255 | end 256 | avg_process_time = avg_process_time / #processing_times 257 | 258 | for _, time in ipairs(processing_times) do 259 | timing_variance = timing_variance + (time - avg_process_time) ^ 2 260 | end 261 | timing_variance = math.sqrt(timing_variance / #processing_times) 262 | 263 | -- Verify performance meets requirements 264 | luaunit.assert_true(max_process_time < 0.002, -- 2ms max processing time per pulse 265 | "Maximum processing time exceeded 2ms") 266 | luaunit.assert_true(timing_variance < 0.001, -- 1ms timing variance 267 | "Timing variance exceeded 1ms") 268 | luaunit.assert_true((peak_memory - start_memory) < 1024, 269 | "Memory usage exceeded 1MB") 270 | end -------------------------------------------------------------------------------- /lib/pattern.lua: -------------------------------------------------------------------------------- 1 | local pattern = {} 2 | 3 | local quantiser = include("mosaic/lib/quantiser") 4 | local m_clock = include("mosaic/lib/clock/m_clock") 5 | local divisions = include("mosaic/lib/clock/divisions") 6 | 7 | local program = program 8 | 9 | local notes = program.initialise_64_table({}) 10 | local lengths = program.initialise_64_table({}) 11 | local velocities = program.initialise_64_table({}) 12 | 13 | -- Helper variables 14 | local update_timer_id = nil 15 | local throttle_time = 0.001 16 | local currently_processing = false 17 | 18 | local unpack = table.unpack 19 | local insert = table.insert 20 | local sort = table.sort 21 | local pairs = pairs 22 | 23 | -- Pre-allocate tables 24 | local default_trig_values = {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0} 25 | local default_lengths = {1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1} 26 | local default_note_values = {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0} 27 | local default_note_mask_values = {-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1} 28 | local default_velocity_values = {100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100} 29 | 30 | local function sync_pattern_values(merged_pattern, pattern, s) 31 | merged_pattern.lengths[s] = pattern.lengths[s] 32 | merged_pattern.velocity_values[s] = pattern.velocity_values[s] 33 | merged_pattern.note_values[s] = pattern.note_values[s] 34 | merged_pattern.note_mask_values[s] = pattern.note_mask_values[s] 35 | return merged_pattern 36 | end 37 | 38 | local function extract_pattern_number(merge_mode) 39 | local prefix = "pattern_number_" 40 | if merge_mode:sub(1, #prefix) == prefix then 41 | return tonumber(merge_mode:sub(#prefix + 1)) 42 | end 43 | return nil 44 | end 45 | 46 | function pattern.get_and_merge_patterns(channel, trig_merge_mode, note_merge_mode, velocity_merge_mode, length_merge_mode) 47 | local selected_song_pattern = program.get_selected_song_pattern() 48 | local merged_pattern = { 49 | trig_values = {unpack(default_trig_values)}, 50 | lengths = {unpack(default_lengths)}, 51 | note_values = {unpack(default_note_values)}, 52 | note_mask_values = {unpack(default_note_mask_values)}, 53 | velocity_values = {unpack(default_velocity_values)}, 54 | merged_notes = {} 55 | } 56 | local skip_bits = {unpack(default_trig_values)} 57 | local only_bits = {unpack(default_trig_values)} 58 | 59 | local pattern_channel = selected_song_pattern.channels[channel] 60 | local patterns = selected_song_pattern.patterns 61 | 62 | for i = 1, 64 do 63 | notes[i] = {} 64 | lengths[i] = {} 65 | velocities[i] = {} 66 | end 67 | 68 | local function do_moded_merge(pattern_number, is_pattern_trig_one, s, mode, values, merged_values, pushed_values) 69 | if mode == "pattern_number_" .. pattern_number then 70 | merged_values[s] = values[s] 71 | elseif mode == "up" or mode == "down" or mode == "average" then 72 | if is_pattern_trig_one then 73 | insert(pushed_values[s], values[s]) 74 | end 75 | end 76 | end 77 | 78 | local patterns_to_process = pattern_channel.selected_patterns 79 | 80 | local function process_merge_mode(merge_mode) 81 | if merge_mode then 82 | local pattern_number = extract_pattern_number(merge_mode) 83 | if pattern_number and patterns_to_process[pattern_number] == nil then 84 | patterns_to_process[pattern_number] = false 85 | end 86 | end 87 | end 88 | 89 | process_merge_mode(note_merge_mode) 90 | process_merge_mode(velocity_merge_mode) 91 | process_merge_mode(length_merge_mode) 92 | 93 | for pattern_number, pattern_enabled in pairs(patterns_to_process) do 94 | local pattern = patterns[pattern_number] 95 | 96 | for s = 1, 64 do 97 | local is_pattern_trig_one = pattern.trig_values[s] == 1 98 | if pattern_enabled then 99 | if trig_merge_mode == "skip" then 100 | if is_pattern_trig_one and merged_pattern.trig_values[s] < 1 and skip_bits[s] < 1 then 101 | merged_pattern = sync_pattern_values(merged_pattern, pattern, s) 102 | merged_pattern.trig_values[s] = 1 103 | elseif is_pattern_trig_one and merged_pattern.trig_values[s] == 1 then 104 | merged_pattern.trig_values[s] = 0 105 | skip_bits[s] = 1 106 | end 107 | elseif trig_merge_mode == "only" then 108 | if is_pattern_trig_one and merged_pattern.trig_values[s] < 1 and only_bits[s] == 0 then 109 | only_bits[s] = 1 110 | merged_pattern.trig_values[s] = 0 111 | elseif is_pattern_trig_one and only_bits[s] == 1 then 112 | merged_pattern.trig_values[s] = 1 113 | end 114 | elseif trig_merge_mode == "all" and is_pattern_trig_one then 115 | merged_pattern.trig_values[s] = 1 116 | end 117 | end 118 | 119 | local is_positive_step_trig_mask = program.get_step_trig_masks(channel) and program.get_step_trig_masks(channel)[s] == 1 120 | local should_process_note_merge_mode = is_pattern_trig_one or is_positive_step_trig_mask or (note_merge_mode and extract_pattern_number(note_merge_mode)) 121 | local should_process_velocity_merge_mode = is_pattern_trig_one or is_positive_step_trig_mask or (velocity_merge_mode and extract_pattern_number(velocity_merge_mode)) 122 | local should_process_length_merge_mode = is_pattern_trig_one or is_positive_step_trig_mask or (length_merge_mode and extract_pattern_number(length_merge_mode)) 123 | 124 | if should_process_note_merge_mode then 125 | do_moded_merge(pattern_number, true, s, note_merge_mode, pattern.note_values, merged_pattern.note_values, notes) 126 | end 127 | if should_process_velocity_merge_mode then 128 | do_moded_merge(pattern_number, true, s, velocity_merge_mode, pattern.velocity_values, merged_pattern.velocity_values, velocities) 129 | end 130 | if should_process_length_merge_mode then 131 | do_moded_merge(pattern_number, true, s, length_merge_mode, pattern.lengths, merged_pattern.lengths, lengths) 132 | end 133 | end 134 | end 135 | 136 | local function do_mode_calculation(mode, s, values, merged_values) 137 | if mode == "up" or mode == "down" or mode == "average" then 138 | if not values[s] or #values[s] == 0 then 139 | merged_values[s] = 0 140 | elseif #values[s] == 1 then 141 | merged_values[s] = values[s][1] 142 | else 143 | sort(values[s]) 144 | local min_value = values[s][1] 145 | local max_value = values[s][#values[s]] 146 | local average = fn.average_table_values(values[s]) 147 | if mode == "up" then 148 | merged_values[s] = average + (max_value - min_value) 149 | elseif mode == "down" then 150 | merged_values[s] = min_value - (average - min_value) 151 | else 152 | merged_values[s] = average 153 | end 154 | merged_pattern.merged_notes[s] = true 155 | end 156 | end 157 | end 158 | 159 | local step_trig_masks = program.get_step_trig_masks(channel) 160 | local step_note_masks = program.get_step_note_masks(channel) 161 | local step_velocity_masks = program.get_step_velocity_masks(channel) 162 | local step_length_masks = program.get_step_length_masks(channel) 163 | local channel_data = program.get_channel(program.get().selected_song_pattern, channel) 164 | 165 | for s = 1, 64 do 166 | do_mode_calculation(note_merge_mode, s, notes, merged_pattern.note_values) 167 | do_mode_calculation(velocity_merge_mode, s, velocities, merged_pattern.velocity_values) 168 | do_mode_calculation(length_merge_mode, s, lengths, merged_pattern.lengths) 169 | 170 | if step_trig_masks[s] then 171 | merged_pattern.trig_values[s] = step_trig_masks[s] 172 | elseif channel_data.trig_mask and channel_data.trig_mask ~= -1 then 173 | merged_pattern.trig_values[s] = channel_data.trig_mask 174 | end 175 | 176 | if step_note_masks[s] then 177 | merged_pattern.note_mask_values[s] = step_note_masks[s] 178 | elseif channel_data.note_mask and channel_data.note_mask ~= -1 then 179 | merged_pattern.note_mask_values[s] = channel_data.note_mask 180 | end 181 | 182 | if step_velocity_masks[s] then 183 | merged_pattern.velocity_values[s] = step_velocity_masks[s] 184 | elseif channel_data.velocity_mask and channel_data.velocity_mask ~= -1 then 185 | merged_pattern.velocity_values[s] = channel_data.velocity_mask 186 | end 187 | 188 | if step_length_masks[s] then 189 | merged_pattern.lengths[s] = step_length_masks[s] 190 | elseif channel_data.length_mask and channel_data.lengths_mask ~= -1 then 191 | merged_pattern.lengths[s] = program.get_length_mask(channel_data) 192 | end 193 | end 194 | 195 | return merged_pattern 196 | end 197 | 198 | pattern.update_working_patterns = scheduler.debounce(function() 199 | for c = 1, 16 do 200 | pattern.update_working_pattern(c, program.get_selected_song_pattern()) 201 | coroutine.yield() 202 | end 203 | end, throttle_time) 204 | 205 | function pattern.update_working_pattern(c, song_pattern) 206 | 207 | local channel_pattern = song_pattern.channels[c] 208 | channel_pattern.working_pattern = pattern.get_and_merge_patterns( 209 | c, 210 | channel_pattern.trig_merge_mode, 211 | channel_pattern.note_merge_mode, 212 | channel_pattern.velocity_merge_mode, 213 | channel_pattern.length_merge_mode 214 | ) 215 | end 216 | 217 | return pattern -------------------------------------------------------------------------------- /lib/tests/lib/program_tests.lua: -------------------------------------------------------------------------------- 1 | include("mosaic/lib/tests/helpers/mocks/params_mock") 2 | 3 | local function setup() 4 | program.init() 5 | globals.reset() 6 | params.reset() 7 | end 8 | 9 | 10 | function test_program_should_get_and_set_step_trig_mask() 11 | setup() 12 | local channel = program.get_channel(1, 1) 13 | channel.step_trig_masks[1] = 1 14 | 15 | luaunit.assert_equals(channel.step_trig_masks[1], 1) 16 | end 17 | 18 | function test_program_should_get_and_set_step_note_mask() 19 | setup() 20 | local channel = program.get_channel(1, 1) 21 | channel.step_note_masks[1] = 60 22 | 23 | luaunit.assert_equals(channel.step_note_masks[1], 60) 24 | end 25 | 26 | function test_program_should_get_and_set_step_velocity_mask() 27 | setup() 28 | local channel = program.get_channel(1, 1) 29 | channel.step_velocity_masks[1] = 100 30 | 31 | luaunit.assert_equals(channel.step_velocity_masks[1], 100) 32 | end 33 | 34 | function test_program_should_get_and_set_step_length_mask() 35 | setup() 36 | local channel = program.get_channel(1, 1) 37 | channel.step_length_masks[1] = 4 38 | 39 | luaunit.assert_equals(channel.step_length_masks[1], 4) 40 | end 41 | 42 | function test_program_should_get_and_set_step_chord_mask() 43 | setup() 44 | local channel = program.get_channel(1, 1) 45 | if not channel.step_chord_masks then 46 | channel.step_chord_masks = {} 47 | end 48 | channel.step_chord_masks[1] = {1, 3, 5} 49 | 50 | local chord_mask = channel.step_chord_masks[1] 51 | luaunit.assert_equals(chord_mask[1], 1) 52 | luaunit.assert_equals(chord_mask[2], 3) 53 | luaunit.assert_equals(chord_mask[3], 5) 54 | end 55 | 56 | function test_program_should_handle_multiple_song_patterns() 57 | setup() 58 | local channel1 = program.get_channel(1, 1) 59 | local channel2 = program.get_channel(2, 1) 60 | 61 | channel1.step_note_masks[1] = 60 62 | channel2.step_note_masks[1] = 64 63 | 64 | luaunit.assert_equals(channel1.step_note_masks[1], 60) 65 | luaunit.assert_equals(channel2.step_note_masks[1], 64) 66 | end 67 | 68 | function test_program_should_handle_multiple_channels() 69 | setup() 70 | local channel1 = program.get_channel(1, 1) 71 | local channel2 = program.get_channel(1, 2) 72 | 73 | channel1.step_note_masks[1] = 60 74 | channel2.step_note_masks[1] = 64 75 | 76 | luaunit.assert_equals(channel1.step_note_masks[1], 60) 77 | luaunit.assert_equals(channel2.step_note_masks[1], 64) 78 | end 79 | 80 | function test_program_should_handle_multiple_steps() 81 | setup() 82 | local channel = program.get_channel(1, 1) 83 | 84 | channel.step_note_masks[1] = 60 85 | channel.step_note_masks[2] = 64 86 | 87 | luaunit.assert_equals(channel.step_note_masks[1], 60) 88 | luaunit.assert_equals(channel.step_note_masks[2], 64) 89 | end 90 | 91 | function test_program_should_handle_nil_masks() 92 | setup() 93 | local channel = program.get_channel(1, 1) 94 | channel.step_note_masks[1] = nil 95 | 96 | luaunit.assert_equals(channel.step_note_masks[1], nil) 97 | end 98 | 99 | function test_program_should_initialize_chord_masks_table() 100 | setup() 101 | local channel = program.get_channel(1, 1) 102 | if not channel.step_chord_masks then 103 | channel.step_chord_masks = {} 104 | end 105 | channel.step_chord_masks[1] = {1, 3, 5} 106 | 107 | luaunit.assert_not_nil(channel.step_chord_masks) 108 | luaunit.assert_equals(type(channel.step_chord_masks), "table") 109 | end 110 | 111 | function test_program_should_maintain_channel_numbers() 112 | setup() 113 | local channel1 = program.get_channel(1, 1) 114 | local channel2 = program.get_channel(1, 2) 115 | 116 | luaunit.assert_equals(channel1.number, 1) 117 | luaunit.assert_equals(channel2.number, 2) 118 | end 119 | 120 | function test_get_next_trig_lock_step_basic_wrap_when_enabled() 121 | setup() 122 | local channel = program.get_channel(1, 1) 123 | 124 | -- Set up trig locks at steps 5 and 10 125 | if not channel.step_trig_lock_banks then channel.step_trig_lock_banks = {} end 126 | channel.step_trig_lock_banks[5] = {[1] = 64} 127 | channel.step_trig_lock_banks[10] = {[1] = 100} 128 | 129 | -- Enable wrapping 130 | params:set("wrap_param_slides", 2) 131 | 132 | -- From step 12, should wrap to step 5 133 | local result = program.get_next_trig_lock_step(channel, 12, 1) 134 | luaunit.assert_equals(result.step, 5) 135 | luaunit.assert_equals(result.should_wrap, true) 136 | end 137 | 138 | function test_get_next_trig_lock_step_no_wrap_when_disabled() 139 | setup() 140 | local channel = program.get_channel(1, 1) 141 | 142 | -- Set up trig locks at steps 5 and 10 143 | if not channel.step_trig_lock_banks then channel.step_trig_lock_banks = {} end 144 | channel.step_trig_lock_banks[5] = {[1] = 64} 145 | channel.step_trig_lock_banks[10] = {[1] = 100} 146 | 147 | -- Disable wrapping 148 | params:set("wrap_param_slides", 1) 149 | 150 | -- From step 12, should not wrap 151 | local result = program.get_next_trig_lock_step(channel, 12, 1) 152 | luaunit.assert_nil(result) 153 | end 154 | 155 | function test_get_next_trig_lock_step_no_cross_pattern_in_song_mode() 156 | setup() 157 | 158 | -- Set up current pattern trig locks 159 | local c1_song_pattern_1 = program.get_channel(1, 1) 160 | if not c1_song_pattern_1.step_trig_lock_banks then c1_song_pattern_1.step_trig_lock_banks = {} end 161 | program.add_step_param_trig_lock_to_channel(c1_song_pattern_1, 5, 1, 64) 162 | 163 | -- Set up next pattern trig locks 164 | local c1_song_pattern_2 = program.get_channel(2, 1) 165 | if not c1_song_pattern_2.step_trig_lock_banks then c1_song_pattern_2.step_trig_lock_banks = {} end 166 | program.add_step_param_trig_lock_to_channel(c1_song_pattern_2, 3, 1, 100) 167 | 168 | -- Enable song mode 169 | params:set("song_mode", 2) 170 | 171 | -- Set up program state for next pattern 172 | program.get().selected_song_pattern = 1 173 | program.get().next_song_pattern = 2 174 | program.get().song_patterns[2].active = true 175 | 176 | -- Test that we don't find trig lock in next pattern 177 | local result = program.get_next_trig_lock_step(c1_song_pattern_1, 5, 1) 178 | luaunit.assert_nil(result) 179 | end 180 | 181 | function test_get_next_trig_lock_step_returns_value() 182 | setup() 183 | local channel = program.get_channel(1, 1) 184 | 185 | -- Set up trig lock with specific value 186 | if not channel.step_trig_lock_banks then channel.step_trig_lock_banks = {} end 187 | channel.step_trig_lock_banks[5] = {[1] = 64} 188 | 189 | -- Check that value is returned in result 190 | local result = program.get_next_trig_lock_step(channel, 1, 1) 191 | luaunit.assert_equals(result.value, 64) 192 | end 193 | 194 | function test_get_next_trig_lock_step_wrap_from_last_step() 195 | setup() 196 | local channel = program.get_channel(1, 1) 197 | 198 | -- Set up trig lock at step 5 199 | if not channel.step_trig_lock_banks then channel.step_trig_lock_banks = {} end 200 | channel.step_trig_lock_banks[5] = {[1] = 64} 201 | 202 | -- Enable wrapping 203 | params:set("wrap_param_slides", 2) 204 | 205 | -- From step 64 (last step), should wrap to step 5 206 | local result = program.get_next_trig_lock_step(channel, 64, 1) 207 | luaunit.assert_equals(result.step, 5) 208 | luaunit.assert_equals(result.should_wrap, true) 209 | end 210 | 211 | function test_get_next_trig_lock_step_wrap_with_single_trig_lock() 212 | setup() 213 | local channel = program.get_channel(1, 1) 214 | 215 | -- Set up single trig lock at step 5 216 | if not channel.step_trig_lock_banks then channel.step_trig_lock_banks = {} end 217 | channel.step_trig_lock_banks[5] = {[1] = 64} 218 | 219 | -- Enable wrapping 220 | params:set("wrap_param_slides", 2) 221 | 222 | -- Should find same trig lock repeatedly when wrapping 223 | local result1 = program.get_next_trig_lock_step(channel, 6, 1) 224 | luaunit.assert_equals(result1.step, 5) 225 | luaunit.assert_equals(result1.should_wrap, true) 226 | 227 | local result2 = program.get_next_trig_lock_step(channel, result1.step + 1, 1) 228 | luaunit.assert_equals(result2.step, 5) 229 | luaunit.assert_equals(result2.should_wrap, true) 230 | end 231 | 232 | function test_get_next_trig_lock_step_wrap_with_multiple_parameters() 233 | setup() 234 | local channel = program.get_channel(1, 1) 235 | 236 | -- Set up trig locks for different parameters 237 | if not channel.step_trig_lock_banks then channel.step_trig_lock_banks = {} end 238 | channel.step_trig_lock_banks[5] = {[1] = 64, [2] = 100} 239 | 240 | -- Enable wrapping 241 | params:set("wrap_param_slides", 2) 242 | 243 | -- Should wrap correctly for each parameter independently 244 | local result1 = program.get_next_trig_lock_step(channel, 6, 1) 245 | luaunit.assert_equals(result1.step, 5) 246 | luaunit.assert_equals(result1.value, 64) 247 | luaunit.assert_equals(result1.should_wrap, true) 248 | 249 | local result2 = program.get_next_trig_lock_step(channel, 6, 2) 250 | luaunit.assert_equals(result2.step, 5) 251 | luaunit.assert_equals(result2.value, 100) 252 | luaunit.assert_equals(result2.should_wrap, true) 253 | end 254 | 255 | function test_get_next_trig_lock_step_wrap_at_step_one() 256 | setup() 257 | local channel = program.get_channel(1, 1) 258 | 259 | -- Set up trig lock at last step 260 | if not channel.step_trig_lock_banks then channel.step_trig_lock_banks = {} end 261 | channel.step_trig_lock_banks[64] = {[1] = 64} 262 | 263 | -- Enable wrapping 264 | params:set("wrap_param_slides", 2) 265 | 266 | -- From step 1, should find step 64 without wrapping 267 | local result = program.get_next_trig_lock_step(channel, 1, 1) 268 | luaunit.assert_equals(result.step, 64) 269 | luaunit.assert_equals(result.should_wrap, nil) 270 | end 271 | 272 | function test_get_next_trig_lock_step_empty_steps_between_locks() 273 | setup() 274 | local channel = program.get_channel(1, 1) 275 | 276 | -- Set up trig locks with gaps 277 | if not channel.step_trig_lock_banks then channel.step_trig_lock_banks = {} end 278 | channel.step_trig_lock_banks[5] = {[1] = 64} 279 | channel.step_trig_lock_banks[50] = {[1] = 100} 280 | 281 | -- Enable wrapping 282 | params:set("wrap_param_slides", 2) 283 | 284 | -- Should handle large gaps between trig locks 285 | local result1 = program.get_next_trig_lock_step(channel, 6, 1) 286 | luaunit.assert_equals(result1.step, 50) 287 | 288 | local result2 = program.get_next_trig_lock_step(channel, 51, 1) 289 | luaunit.assert_equals(result2.step, 5) 290 | luaunit.assert_equals(result2.should_wrap, true) 291 | end -------------------------------------------------------------------------------- /lib/clock/divisions.lua: -------------------------------------------------------------------------------- 1 | local divisions = {} 2 | 3 | divisions.clock_divisions = { 4 | {name = "x16", value = 16, type = "clock_multiplication"}, 5 | {name = "x12", value = 12, type = "clock_multiplication"}, 6 | {name = "x8", value = 8, type = "clock_multiplication"}, 7 | {name = "x6", value = 6, type = "clock_multiplication"}, 8 | {name = "x5.3", value = 5.3, type = "clock_multiplication"}, 9 | {name = "x5", value = 5, type = "clock_multiplication"}, 10 | {name = "x4", value = 4, type = "clock_multiplication"}, 11 | {name = "x3", value = 3, type = "clock_multiplication"}, 12 | {name = "x2.6", value = 2.6, type = "clock_multiplication"}, 13 | {name = "x2", value = 2, type = "clock_multiplication"}, 14 | {name = "x1.5", value = 1.5, type = "clock_multiplication"}, 15 | {name = "x1.3", value = 1.3, type = "clock_multiplication"}, 16 | {name = "/1", value = 1, type = "clock_division"}, 17 | {name = "/1.5", value = 1.5, type = "clock_division"}, 18 | {name = "/2", value = 2, type = "clock_division"}, 19 | {name = "/2.6", value = 2.6, type = "clock_division"}, 20 | {name = "/3", value = 3, type = "clock_division"}, 21 | {name = "/4", value = 4, type = "clock_division"}, 22 | {name = "/5", value = 5, type = "clock_division"}, 23 | {name = "/5.3", value = 5.3, type = "clock_division"}, 24 | {name = "/6", value = 6, type = "clock_division"}, 25 | {name = "/7", value = 7, type = "clock_division"}, 26 | {name = "/8", value = 8, type = "clock_division"}, 27 | {name = "/9", value = 9, type = "clock_division"}, 28 | {name = "/10", value = 10, type = "clock_division"}, 29 | {name = "/11", value = 11, type = "clock_division"}, 30 | {name = "/12", value = 12, type = "clock_division"}, 31 | {name = "/13", value = 13, type = "clock_division"}, 32 | {name = "/14", value = 14, type = "clock_division"}, 33 | {name = "/15", value = 15, type = "clock_division"}, 34 | {name = "/16", value = 16, type = "clock_division"}, 35 | {name = "/17", value = 17, type = "clock_division"}, 36 | {name = "/19", value = 19, type = "clock_division"}, 37 | {name = "/21", value = 21, type = "clock_division"}, 38 | {name = "/23", value = 23, type = "clock_division"}, 39 | {name = "/24", value = 24, type = "clock_division"}, 40 | {name = "/25", value = 25, type = "clock_division"}, 41 | {name = "/27", value = 27, type = "clock_division"}, 42 | {name = "/29", value = 29, type = "clock_division"}, 43 | {name = "/32", value = 32, type = "clock_division"}, 44 | {name = "/40", value = 40, type = "clock_division"}, 45 | {name = "/48", value = 48, type = "clock_division"}, 46 | {name = "/56", value = 56, type = "clock_division"}, 47 | {name = "/64", value = 64, type = "clock_division"}, 48 | {name = "/96", value = 96, type = "clock_division"}, 49 | {name = "/101", value = 101, type = "clock_division"}, 50 | {name = "/128", value = 128, type = "clock_division"} 51 | } 52 | 53 | 54 | divisions.clock_divisions_labels = { 55 | "x16", 56 | "x12", 57 | "x8", 58 | "x6", 59 | "x5.3", 60 | "x5", 61 | "x4", 62 | "x3", 63 | "x2.6", 64 | "x2", 65 | "x1.5", 66 | "x1.3", 67 | "/1", 68 | "/1.5", 69 | "/2", 70 | "/2.6", 71 | "/3", 72 | "/4", 73 | "/5", 74 | "/5.3", 75 | "/6", 76 | "/7", 77 | "/8", 78 | "/9", 79 | "/10", 80 | "/11", 81 | "/12", 82 | "/13", 83 | "/14", 84 | "/15", 85 | "/16", 86 | "/17", 87 | "/19", 88 | "/21", 89 | "/23", 90 | "/24", 91 | "/25", 92 | "/27", 93 | "/29", 94 | "/32", 95 | "/40", 96 | "/48", 97 | "/56", 98 | "/64", 99 | "/96", 100 | "/101", 101 | "/128" 102 | } 103 | 104 | divisions.note_divisions = { 105 | {name = "1/24", value = 1/24}, 106 | {name = "1/12", value = 1/12}, 107 | {name = "1/8", value = 1/8}, 108 | {name = "1/6", value = 1/6}, 109 | {name = "1/4", value = 1/4}, 110 | {name = "1/3", value = 1/3}, 111 | {name = "3/8", value = 3/8}, 112 | {name = "1/2", value = 1/2}, 113 | {name = "5/8", value = 5/8}, 114 | {name = "2/3", value = 2/3}, 115 | {name = "3/4", value = 3/4}, 116 | {name = "5/6", value = 5/6}, 117 | {name = "7/8", value = 7/8}, 118 | {name = "1", value = 1}, 119 | {name = "1.25", value = 1.25}, 120 | {name = "1.5", value = 1.5}, 121 | {name = "1.75", value = 1.75}, 122 | {name = "2", value = 2}, 123 | {name = "2.25", value = 2.25}, 124 | {name = "2.5", value = 2.5}, 125 | {name = "2.75", value = 2.75}, 126 | {name = "3", value = 3}, 127 | {name = "3.25", value = 3.25}, 128 | {name = "3.5", value = 3.5}, 129 | {name = "3.75", value = 3.75}, 130 | {name = "4", value = 4}, 131 | {name = "4.5", value = 4.5}, 132 | {name = "5", value = 5}, 133 | {name = "5.5", value = 5.5}, 134 | {name = "6", value = 6}, 135 | {name = "6.5", value = 6.5}, 136 | {name = "7", value = 7}, 137 | {name = "7.5", value = 7.5}, 138 | {name = "8", value = 8}, 139 | {name = "9", value = 9}, 140 | {name = "10", value = 10}, 141 | {name = "11", value = 11}, 142 | {name = "12", value = 12}, 143 | {name = "13", value = 13}, 144 | {name = "14", value = 14}, 145 | {name = "15", value = 15}, 146 | {name = "16", value = 16}, 147 | {name = "17", value = 17}, 148 | {name = "18", value = 18}, 149 | {name = "19", value = 19}, 150 | {name = "20", value = 20}, 151 | {name = "21", value = 21}, 152 | {name = "22", value = 22}, 153 | {name = "23", value = 23}, 154 | {name = "24", value = 24}, 155 | {name = "25", value = 25}, 156 | {name = "26", value = 26}, 157 | {name = "27", value = 27}, 158 | {name = "28", value = 28}, 159 | {name = "29", value = 29}, 160 | {name = "30", value = 30}, 161 | {name = "31", value = 31}, 162 | {name = "32", value = 32}, 163 | {name = "33", value = 33}, 164 | {name = "34", value = 34}, 165 | {name = "35", value = 35}, 166 | {name = "36", value = 36}, 167 | {name = "37", value = 37}, 168 | {name = "38", value = 38}, 169 | {name = "40", value = 40}, 170 | {name = "42", value = 42}, 171 | {name = "44", value = 44}, 172 | {name = "46", value = 46}, 173 | {name = "48", value = 48}, 174 | {name = "50", value = 50}, 175 | {name = "52", value = 52}, 176 | {name = "54", value = 54}, 177 | {name = "56", value = 56}, 178 | {name = "58", value = 58}, 179 | {name = "60", value = 60}, 180 | {name = "62", value = 62}, 181 | {name = "64", value = 64}, 182 | {name = "68", value = 68}, 183 | {name = "72", value = 72}, 184 | {name = "76", value = 76}, 185 | {name = "80", value = 80}, 186 | {name = "84", value = 84}, 187 | {name = "88", value = 88}, 188 | {name = "92", value = 92}, 189 | {name = "96", value = 96}, 190 | {name = "104", value = 104}, 191 | {name = "112", value = 112}, 192 | {name = "120", value = 120}, 193 | {name = "128", value = 128} 194 | } 195 | 196 | divisions.note_division_labels = { 197 | "X", 198 | "1/24", 199 | "1/12", 200 | "1/8", 201 | "1/6", 202 | "1/4", 203 | "1/3", 204 | "3/8", 205 | "1/2", 206 | "5/8", 207 | "2/3", 208 | "3/4", 209 | "5/6", 210 | "7/8", 211 | "1", 212 | "1.25", 213 | "1.5", 214 | "1.75", 215 | "2", 216 | "2.25", 217 | "2.5", 218 | "2.75", 219 | "3", 220 | "3.25", 221 | "3.5", 222 | "3.75", 223 | "4", 224 | "4.5", 225 | "5", 226 | "5.5", 227 | "6", 228 | "6.5", 229 | "7", 230 | "7.5", 231 | "8", 232 | "9", 233 | "10", 234 | "11", 235 | "12", 236 | "13", 237 | "14", 238 | "15", 239 | "16", 240 | "17", 241 | "18", 242 | "19", 243 | "20", 244 | "21", 245 | "22", 246 | "23", 247 | "24", 248 | "25", 249 | "26", 250 | "27", 251 | "28", 252 | "29", 253 | "30", 254 | "31", 255 | "32", 256 | "33", 257 | "34", 258 | "35", 259 | "36", 260 | "37", 261 | "38", 262 | "40", 263 | "42", 264 | "44", 265 | "46", 266 | "48", 267 | "50", 268 | "52", 269 | "54", 270 | "56", 271 | "58", 272 | "60", 273 | "62", 274 | "64", 275 | "68", 276 | "72", 277 | "76", 278 | "80", 279 | "84", 280 | "88", 281 | "92", 282 | "96", 283 | "104", 284 | "112", 285 | "120", 286 | "128" 287 | } 288 | 289 | divisions.note_division_values = { 290 | 1/24, 291 | 1/12, 292 | 1/8, 293 | 1/6, 294 | 1/4, 295 | 1/3, 296 | 3/8, 297 | 1/2, 298 | 5/8, 299 | 2/3, 300 | 3/4, 301 | 5/6, 302 | 7/8, 303 | 1, 304 | 1.25, 305 | 1.5, 306 | 1.75, 307 | 2, 308 | 2.25, 309 | 2.5, 310 | 2.75, 311 | 3, 312 | 3.25, 313 | 3.5, 314 | 3.75, 315 | 4, 316 | 4.5, 317 | 5, 318 | 5.5, 319 | 6, 320 | 6.5, 321 | 7, 322 | 7.5, 323 | 8, 324 | 9, 325 | 10, 326 | 11, 327 | 12, 328 | 13, 329 | 14, 330 | 15, 331 | 16, 332 | 17, 333 | 18, 334 | 19, 335 | 20, 336 | 21, 337 | 22, 338 | 23, 339 | 24, 340 | 25, 341 | 26, 342 | 27, 343 | 28, 344 | 29, 345 | 30, 346 | 31, 347 | 32, 348 | 33, 349 | 34, 350 | 35, 351 | 36, 352 | 37, 353 | 38, 354 | 40, 355 | 42, 356 | 44, 357 | 46, 358 | 48, 359 | 50, 360 | 52, 361 | 54, 362 | 56, 363 | 58, 364 | 60, 365 | 62, 366 | 64, 367 | 68, 368 | 72, 369 | 76, 370 | 80, 371 | 84, 372 | 88, 373 | 92, 374 | 96, 375 | 104, 376 | 112, 377 | 120, 378 | 128 379 | } 380 | 381 | divisions.note_division_indexes = { 382 | [1/24] = 1, 383 | [1/12] = 2, 384 | [1/8] = 3, 385 | [1/6] = 4, 386 | [1/4] = 5, 387 | [1/3] = 6, 388 | [3/8] = 7, 389 | [1/2] = 8, 390 | [5/8] = 9, 391 | [2/3] = 10, 392 | [3/4] = 11, 393 | [5/6] = 12, 394 | [7/8] = 13, 395 | [1] = 14, 396 | [1.25] = 15, 397 | [1.5] = 16, 398 | [1.75] = 17, 399 | [2] = 18, 400 | [2.25] = 19, 401 | [2.5] = 20, 402 | [2.75] = 21, 403 | [3] = 22, 404 | [3.25] = 23, 405 | [3.5] = 24, 406 | [3.75] = 25, 407 | [4] = 26, 408 | [4.5] = 27, 409 | [5] = 28, 410 | [5.5] = 29, 411 | [6] = 30, 412 | [6.5] = 31, 413 | [7] = 32, 414 | [7.5] = 33, 415 | [8] = 34, 416 | [9] = 35, 417 | [10] = 36, 418 | [11] = 37, 419 | [12] = 38, 420 | [13] = 39, 421 | [14] = 40, 422 | [15] = 41, 423 | [16] = 42, 424 | [17] = 43, 425 | [18] = 44, 426 | [19] = 45, 427 | [20] = 46, 428 | [21] = 47, 429 | [22] = 48, 430 | [23] = 49, 431 | [24] = 50, 432 | [25] = 51, 433 | [26] = 52, 434 | [27] = 53, 435 | [28] = 54, 436 | [29] = 55, 437 | [30] = 56, 438 | [31] = 57, 439 | [32] = 58, 440 | [33] = 59, 441 | [34] = 60, 442 | [35] = 61, 443 | [36] = 62, 444 | [37] = 63, 445 | [38] = 64, 446 | [40] = 65, 447 | [42] = 66, 448 | [44] = 67, 449 | [46] = 68, 450 | [48] = 69, 451 | [50] = 70, 452 | [52] = 71, 453 | [54] = 72, 454 | [56] = 73, 455 | [58] = 74, 456 | [60] = 75, 457 | [62] = 76, 458 | [64] = 77, 459 | [68] = 78, 460 | [72] = 79, 461 | [76] = 80, 462 | [80] = 81, 463 | [84] = 82, 464 | [88] = 83, 465 | [92] = 84, 466 | [96] = 85, 467 | [104] = 86, 468 | [112] = 87, 469 | [120] = 88, 470 | [128] = 89 471 | } 472 | 473 | 474 | return divisions -------------------------------------------------------------------------------- /lib/pages/channel_edit_page/channel_edit_page_ui_handlers.lua: -------------------------------------------------------------------------------- 1 | -- channel_edit_page_ui_handlers.lua 2 | local channel_edit_page_ui_handlers = {} 3 | local param_manager = include("mosaic/lib/devices/param_manager") 4 | local channel_edit_page_ui_refreshers = include("lib/pages/channel_edit_page/channel_edit_page_ui_refreshers") 5 | 6 | function channel_edit_page_ui_handlers.handle_encoder_two_positive(pages, selectors, dials, trig_lock_page) 7 | local channel_pages = pages.channel_pages 8 | local channel_page_to_index = pages.channel_page_to_index 9 | local scales_pages = pages.scales_pages 10 | local scales_page_to_index = pages.scales_page_to_index 11 | 12 | local mask_selectors = selectors.mask_selectors 13 | local clock_mod_list_selector = selectors.clock_mod_list_selector 14 | local midi_device_vertical_scroll_selector = selectors.midi_device_vertical_scroll_selector 15 | local midi_channel_vertical_scroll_selector = selectors.midi_channel_vertical_scroll_selector 16 | local device_map_vertical_scroll_selector = selectors.device_map_vertical_scroll_selector 17 | local swing_shuffle_type_selector = selectors.swing_shuffle_type_selector 18 | local swing_selector = selectors.swing_selector 19 | local shuffle_feel_selector = selectors.shuffle_feel_selector 20 | local shuffle_basis_selector = selectors.shuffle_basis_selector 21 | local shuffle_amount_selector = selectors.shuffle_amount_selector 22 | 23 | if channel_pages:get_selected_page() == channel_page_to_index["Masks"] then 24 | if mask_selectors.trig:is_selected() then 25 | mask_selectors.trig:deselect() 26 | mask_selectors.note:select() 27 | elseif mask_selectors.note:is_selected() then 28 | mask_selectors.note:deselect() 29 | mask_selectors.velocity:select() 30 | elseif mask_selectors.velocity:is_selected() then 31 | mask_selectors.velocity:deselect() 32 | mask_selectors.length:select() 33 | elseif mask_selectors.length:is_selected() then 34 | mask_selectors.length:deselect() 35 | mask_selectors.chords[1]:select() 36 | elseif mask_selectors.chords[1]:is_selected() then 37 | mask_selectors.chords[1]:deselect() 38 | mask_selectors.chords[2]:select() 39 | elseif mask_selectors.chords[2]:is_selected() then 40 | mask_selectors.chords[2]:deselect() 41 | mask_selectors.chords[3]:select() 42 | elseif mask_selectors.chords[3]:is_selected() then 43 | mask_selectors.chords[3]:deselect() 44 | mask_selectors.chords[4]:select() 45 | end 46 | elseif channel_pages:get_selected_page() == channel_page_to_index["Clock Mods"] then 47 | -- Adjusted navigation for Clock Mods page 48 | local function get_visible_clock_mod_selectors() 49 | local selectors = {clock_mod_list_selector, swing_shuffle_type_selector} 50 | local value = channel_edit_page_ui.get_swing_shuffle_type_selector_value() 51 | if value == 0 then 52 | value = params:get("global_swing_shuffle_type") 53 | end 54 | if value == 1 then 55 | table.insert(selectors, swing_selector) 56 | elseif value == 2 then 57 | table.insert(selectors, shuffle_feel_selector) 58 | table.insert(selectors, shuffle_basis_selector) 59 | table.insert(selectors, shuffle_amount_selector) 60 | end 61 | return selectors 62 | end 63 | 64 | local selectors = get_visible_clock_mod_selectors() 65 | local current_index = nil 66 | for idx, selector in ipairs(selectors) do 67 | if selector:is_selected() then 68 | current_index = idx 69 | break 70 | end 71 | end 72 | 73 | if current_index then 74 | if current_index < #selectors then 75 | selectors[current_index]:deselect() 76 | selectors[current_index + 1]:select() 77 | end 78 | else 79 | -- No selector is currently selected, select the first one 80 | selectors[1]:select() 81 | end 82 | elseif channel_pages:get_selected_page() == channel_page_to_index["Midi Config"] then 83 | local device = fn.get_by_id(device_map.get_devices(), device_map_vertical_scroll_selector:get_selected_item().id) 84 | if midi_channel_vertical_scroll_selector:is_selected() then 85 | if not device.default_midi_device and m_midi.midi_devices_connected() then 86 | midi_channel_vertical_scroll_selector:deselect() 87 | midi_device_vertical_scroll_selector:select() 88 | end 89 | elseif device_map_vertical_scroll_selector:is_selected() then 90 | if not device.default_midi_channel then 91 | device_map_vertical_scroll_selector:deselect() 92 | midi_channel_vertical_scroll_selector:select() 93 | else 94 | if not device.default_midi_device and m_midi.midi_devices_connected() then 95 | device_map_vertical_scroll_selector:deselect() 96 | midi_device_vertical_scroll_selector:select() 97 | end 98 | end 99 | end 100 | elseif channel_pages:get_selected_page() == channel_page_to_index["Trig Locks"] then 101 | if not trig_lock_page:is_sub_page_enabled() then 102 | dials:scroll_next() 103 | end 104 | end 105 | end 106 | 107 | function channel_edit_page_ui_handlers.handle_encoder_two_negative(pages, selectors, dials, trig_lock_page) 108 | local channel_pages = pages.channel_pages 109 | local channel_page_to_index = pages.channel_page_to_index 110 | local scales_pages = pages.scales_pages 111 | local scales_page_to_index = pages.scales_page_to_index 112 | 113 | local mask_selectors = selectors.mask_selectors 114 | local clock_mod_list_selector = selectors.clock_mod_list_selector 115 | local midi_device_vertical_scroll_selector = selectors.midi_device_vertical_scroll_selector 116 | local midi_channel_vertical_scroll_selector = selectors.midi_channel_vertical_scroll_selector 117 | local device_map_vertical_scroll_selector = selectors.device_map_vertical_scroll_selector 118 | local swing_shuffle_type_selector = selectors.swing_shuffle_type_selector 119 | local swing_selector = selectors.swing_selector 120 | local shuffle_feel_selector = selectors.shuffle_feel_selector 121 | local shuffle_basis_selector = selectors.shuffle_basis_selector 122 | local shuffle_amount_selector = selectors.shuffle_amount_selector 123 | 124 | if channel_pages:get_selected_page() == channel_page_to_index["Masks"] then 125 | if mask_selectors.note:is_selected() then 126 | mask_selectors.note:deselect() 127 | mask_selectors.trig:select() 128 | elseif mask_selectors.velocity:is_selected() then 129 | mask_selectors.velocity:deselect() 130 | mask_selectors.note:select() 131 | elseif mask_selectors.length:is_selected() then 132 | mask_selectors.length:deselect() 133 | mask_selectors.velocity:select() 134 | elseif mask_selectors.chords[1]:is_selected() then 135 | mask_selectors.chords[1]:deselect() 136 | mask_selectors.length:select() 137 | elseif mask_selectors.chords[2]:is_selected() then 138 | mask_selectors.chords[2]:deselect() 139 | mask_selectors.chords[1]:select() 140 | elseif mask_selectors.chords[3]:is_selected() then 141 | mask_selectors.chords[3]:deselect() 142 | mask_selectors.chords[2]:select() 143 | elseif mask_selectors.chords[4]:is_selected() then 144 | mask_selectors.chords[4]:deselect() 145 | mask_selectors.chords[3]:select() 146 | end 147 | elseif channel_pages:get_selected_page() == channel_page_to_index["Clock Mods"] then 148 | -- Adjusted navigation for Clock Mods page 149 | local function get_visible_clock_mod_selectors() 150 | local selectors = {clock_mod_list_selector, swing_shuffle_type_selector} 151 | local value = channel_edit_page_ui.get_swing_shuffle_type_selector_value() 152 | if value == 0 then 153 | value = params:get("global_swing_shuffle_type") 154 | end 155 | if value == 1 then 156 | table.insert(selectors, swing_selector) 157 | elseif value == 2 then 158 | table.insert(selectors, shuffle_feel_selector) 159 | table.insert(selectors, shuffle_basis_selector) 160 | table.insert(selectors, shuffle_amount_selector) 161 | end 162 | return selectors 163 | end 164 | 165 | local selectors = get_visible_clock_mod_selectors() 166 | local current_index = nil 167 | for idx, selector in ipairs(selectors) do 168 | if selector:is_selected() then 169 | current_index = idx 170 | break 171 | end 172 | end 173 | 174 | if current_index then 175 | if current_index > 1 then 176 | selectors[current_index]:deselect() 177 | selectors[current_index - 1]:select() 178 | end 179 | else 180 | -- No selector is currently selected, select the last one 181 | selectors[#selectors]:select() 182 | end 183 | elseif channel_pages:get_selected_page() == channel_page_to_index["Midi Config"] then 184 | local device = fn.get_by_id(device_map.get_devices(), device_map_vertical_scroll_selector:get_selected_item().id) 185 | if midi_device_vertical_scroll_selector:is_selected() then 186 | if device.default_midi_channel == nil and m_midi.midi_devices_connected() then 187 | midi_device_vertical_scroll_selector:deselect() 188 | midi_channel_vertical_scroll_selector:select() 189 | elseif device.default_midi_device == nil and m_midi.midi_devices_connected() then 190 | midi_device_vertical_scroll_selector:deselect() 191 | device_map_vertical_scroll_selector:select() 192 | end 193 | elseif midi_channel_vertical_scroll_selector:is_selected() then 194 | midi_channel_vertical_scroll_selector:deselect() 195 | device_map_vertical_scroll_selector:select() 196 | end 197 | elseif channel_pages:get_selected_page() == channel_page_to_index["Trig Locks"] then 198 | if not trig_lock_page:is_sub_page_enabled() then 199 | dials:scroll_previous() 200 | end 201 | end 202 | end 203 | 204 | 205 | function channel_edit_page_ui_handlers.handle_trig_locks_page_change(direction, trig_lock_page, param_select_vertical_scroll_selector, dials) 206 | local channel = program.get_selected_channel() 207 | local dial_index = dials:get_selected_index() 208 | 209 | if trig_lock_page:is_sub_page_enabled() then 210 | param_select_vertical_scroll_selector:scroll(direction) 211 | save_confirm.set_save(function() 212 | norns_param_state_handler.clear_original_param_state(channel.number, dial_index) 213 | param_manager.update_param( 214 | dial_index, 215 | channel, 216 | param_select_vertical_scroll_selector:get_selected_item(), 217 | param_select_vertical_scroll_selector:get_meta_item() 218 | ) 219 | channel_edit_page_ui.refresh_trig_locks() 220 | program.increment_trig_lock_calculator_id(channel, dial_index) 221 | end) 222 | save_confirm.set_cancel(function() end) 223 | else 224 | channel_edit_page_ui.handle_trig_lock_param_change_by_direction(direction, channel, dial_index) 225 | end 226 | end 227 | 228 | return channel_edit_page_ui_handlers 229 | -------------------------------------------------------------------------------- /lib/devices/param_manager.lua: -------------------------------------------------------------------------------- 1 | 2 | 3 | local param_manager = {} 4 | 5 | local first_run = true 6 | 7 | 8 | local function construct_value_formatter(off_value, ui_labels) 9 | local off_val = off_value 10 | return function(param) 11 | local value = param:get() 12 | if not off_value then 13 | return ui_labels and ui_labels[value-off_value+1] or value 14 | end 15 | if value == off_val then 16 | return "X" 17 | else 18 | return ui_labels and ui_labels[value-off_value+1] or value 19 | end 20 | end 21 | end 22 | 23 | function param_manager.init() 24 | for i = 1, 16 do 25 | if params.lookup["midi_device_params_group_channel_" .. i] == nil then 26 | params:add_group("midi_device_params_group_channel_" .. i, "MOSAIC CH " .. i, 180) 27 | params:hide("midi_device_params_group_channel_" .. i) 28 | end 29 | 30 | for j = 1, 180 do 31 | if params.lookup["midi_device_params_channel_" .. i .. "_" .. j] == nil then 32 | -- params:add_number("midi_device_params_channel_" .. i .. "_" .. j, "undefined", -1, 10000, -1) 33 | params:add_control("midi_device_params_channel_" .. i .. "_" .. j, "undefined", controlspec.new(0, 0, 'lin', 0, -1, '', 0)) 34 | params:set_action( 35 | "midi_device_params_channel_" .. i .. "_" .. j, 36 | function(x) 37 | end 38 | ) 39 | params:show("midi_device_params_channel_" .. i .. "_" .. j) 40 | local p = params:lookup_param("midi_device_params_channel_" .. i .. "_" .. j) 41 | p.controlspec.default = -1 42 | p:set(-1) 43 | p.formatter = construct_value_formatter(-1) 44 | 45 | end 46 | end 47 | end 48 | end 49 | 50 | 51 | function param_manager.add_device_params(channel_id, device, c, midi_device, init) 52 | if device and (device.type == "midi" or device.type == "norns") then 53 | -- Set group parameter name and show it 54 | params:lookup_param("midi_device_params_group_channel_" .. channel_id).name = 55 | "MOSAIC CH " .. channel_id .. ": " .. string.upper(device.name) 56 | params:show("midi_device_params_group_channel_" .. channel_id) 57 | 58 | local stock_params = device_map.get_stock_params() 59 | local accumulator = 1 60 | 61 | -- Process stock parameters 62 | for i, val in pairs(stock_params) do 63 | if val and val.id ~= "none" then 64 | 65 | local p = params:lookup_param("midi_device_params_channel_" .. channel_id .. "_" .. i) 66 | 67 | p.default = val.off_value or -1 68 | p.controlspec.minval = val.cc_min_value or -1 69 | p.controlspec.maxval = val.cc_max_value or 127 70 | p.min = val.cc_min_value or -1 71 | p.max = val.cc_max_value or 127 72 | p.controlspec.step = 1 73 | p.controlspec.quantum = 1/((val.cc_max_value - val.cc_min_value) or 127) 74 | p.controlspec.default = val.off_value or -1 75 | 76 | p.name = val.name 77 | 78 | if init == true then 79 | p:set(val.off_value or -1) 80 | end 81 | p.formatter = construct_value_formatter(val.off_value or -1, val.ui_labels) 82 | params:set_action( 83 | "midi_device_params_channel_" .. channel_id .. "_" .. i, 84 | function(x) 85 | channel_edit_page_ui.refresh_trig_lock_values() 86 | autosave_reset() 87 | end 88 | ) 89 | params:show("midi_device_params_channel_" .. channel_id .. "_" .. i) 90 | end 91 | accumulator = accumulator + 1 92 | if val.id == "none" then 93 | params:hide("midi_device_params_channel_" .. channel_id .. "_" .. i) 94 | end 95 | end 96 | 97 | local oob_accumulator = 40 -- ensure we have room to add more stock params without breaking changes 98 | 99 | if device.type == "norns" and device.supports_slew then 100 | local p = params:lookup_param("midi_device_params_channel_" .. channel_id .. "_" .. oob_accumulator) 101 | 102 | p.default = -1 103 | p.controlspec.minval = 0 104 | p.controlspec.maxval = 60 105 | p.min = 0 106 | p.max = 60 107 | p.controlspec.step = 1 108 | p.controlspec.quantum = 1/60 109 | p.controlspec.default = 0 110 | p.name = "Slew" 111 | if init == true then 112 | p:set(0) 113 | end 114 | p.formatter = construct_value_formatter(-1) 115 | params:set_action( 116 | "midi_device_params_channel_" .. channel_id .. "_" .. oob_accumulator, 117 | function(x) 118 | channel_edit_page_ui.refresh_trig_lock_values() 119 | autosave_reset() 120 | end 121 | ) 122 | params:show("midi_device_params_channel_" .. channel_id .. "_" .. oob_accumulator) 123 | oob_accumulator = oob_accumulator + 1 124 | end 125 | 126 | 127 | -- Process device-specific parameters 128 | for k, val in pairs(device.params) do 129 | local i = k + accumulator - 1 130 | if device.type == "midi" and val and val.id ~= "none" and val.param_type ~= "stock" then 131 | local p = params:lookup_param("midi_device_params_channel_" .. channel_id .. "_" .. i) 132 | 133 | if val.nrpn_min_value and val.nrpn_max_value and val.nrpn_lsb and val.nrpn_msb then 134 | p.controlspec.minval = val.nrpn_min_value or -1 135 | p.controlspec.maxval = val.nrpn_max_value or 16383 136 | p.min = val.nrpn_min_value or -1 137 | p.max = val.nrpn_max_value or 16383 138 | p.controlspec.step = 1 139 | p.controlspec.quantum = 1/(((val.nrpn_max_value - val.nrpn_min_value) or 16383) / 127) 140 | p.controlspec.default = val.off_value or -1 141 | else 142 | p.controlspec.minval = val.cc_min_value or -1 143 | p.controlspec.maxval = val.cc_max_value or 127 144 | p.min = val.cc_min_value or -1 145 | p.max = val.cc_max_value or 127 146 | p.controlspec.step = 1 147 | p.controlspec.quantum = 1/(val.cc_max_value - val.cc_min_value) or 127 148 | p.controlspec.default = val.off_value or -1 149 | end 150 | p.name = val.name 151 | if init == true then 152 | p:set(val.off_value or 0) 153 | end 154 | p.formatter = construct_value_formatter(val.off_value, val.ui_labels) 155 | params:set_action( 156 | "midi_device_params_channel_" .. channel_id .. "_" .. i, 157 | function(x) 158 | if x ~= val.off_value then 159 | if val.nrpn_max_value and val.nrpn_lsb and val.nrpn_msb then 160 | m_midi.nrpn(val.nrpn_msb, val.nrpn_lsb, x, c, midi_device) 161 | elseif val.cc_msb and val.cc_max_value then 162 | m_midi.cc(val.cc_msb, val.cc_lsb or nil, x, c, midi_device) 163 | end 164 | channel_edit_page_ui.refresh_trig_lock_values() 165 | end 166 | autosave_reset() 167 | end 168 | ) 169 | params:show("midi_device_params_channel_" .. channel_id .. "_" .. i) 170 | else 171 | params:set_action("midi_device_params_channel_" .. channel_id .. "_" .. i, function(x) end) 172 | params:hide("midi_device_params_channel_" .. channel_id .. "_" .. i) 173 | end 174 | oob_accumulator = i + 1 175 | end 176 | 177 | 178 | -- Hide remaining parameters 179 | for j = oob_accumulator, 180 do 180 | params:set_action("midi_device_params_channel_" .. channel_id .. "_" .. j, function(x) end) 181 | params:hide("midi_device_params_channel_" .. channel_id .. "_" .. j) 182 | end 183 | else 184 | -- Hide group parameter and reset individual parameters 185 | params:hide("midi_device_params_group_channel_" .. channel_id) 186 | for i = 1, 180 do 187 | local p = params:lookup_param("midi_device_params_channel_" .. channel_id .. "_" .. i) 188 | p:set(-1) 189 | p.name = "undefined" 190 | params:set_action("midi_device_params_channel_" .. channel_id .. "_" .. i, function(x) end) 191 | params:hide("midi_device_params_channel_" .. channel_id .. "_" .. i) 192 | end 193 | end 194 | _menu.rebuild_params() 195 | end 196 | 197 | 198 | function param_manager.update_param(index, channel, param, meta_device) 199 | if param.id == "none" then 200 | channel.trig_lock_params[index] = {} 201 | else 202 | channel.trig_lock_params[index] = {} 203 | channel.trig_lock_params[index] = fn.deep_copy(param) 204 | channel.trig_lock_params[index].device_name = meta_device.device_name 205 | channel.trig_lock_params[index].type = meta_device.type 206 | channel.trig_lock_params[index].id = param.id 207 | channel.trig_lock_params[index].param_id = param.id 208 | 209 | if param.param_type == "stock" then 210 | channel.trig_lock_params[index].param_id = fn.get_param_id_from_stock_id(param.param_id or param.id, channel.number) 211 | elseif meta_device.type == "norns" and param.param_id then 212 | channel.trig_lock_params[index].param_id = param.param_id 213 | else 214 | channel.trig_lock_params[index].param_id = string.format("midi_device_params_channel_%d_%d", channel.number, param.index) 215 | end 216 | 217 | end 218 | end 219 | 220 | local function safe_set_param(channel, index, param, meta_device) 221 | if not channel.trig_lock_params then 222 | channel.trig_lock_params = {} 223 | end 224 | 225 | -- If param is nil or empty, just set empty table 226 | if not param or not next(param) then 227 | channel.trig_lock_params[index] = {} 228 | return 229 | end 230 | 231 | -- Create a deep clone of the param 232 | local param_copy = fn.deep_copy(param) 233 | 234 | -- Set the channel-specific fields 235 | param_copy.device_name = meta_device and meta_device.device_name or "" 236 | param_copy.type = meta_device and meta_device.type or "" 237 | param_copy.id = param.id or "" 238 | 239 | -- Always ensure param_id is channel-specific 240 | if param_copy.type == "midi" and param_copy.index then 241 | param_copy.param_id = string.format("midi_device_params_channel_%d_%d", channel.number, param_copy.index) 242 | end 243 | 244 | -- Assign the cloned and modified param 245 | channel.trig_lock_params[index] = param_copy 246 | end 247 | 248 | function param_manager.update_default_params(channel, meta_device) 249 | -- Use the merged parameters with index keys 250 | local device_params = device_map.get_params(meta_device.id) 251 | 252 | for i = 1, 10 do 253 | if meta_device.map_params_automatically and type(meta_device.map_params_automatically) == "table" and meta_device.map_params_automatically[i] then 254 | local id = meta_device.map_params_automatically[i] 255 | if type(id) == "string" then 256 | local param = fn.find_in_table_by_id(device_params or {}, id) 257 | safe_set_param(channel, i, param, meta_device) 258 | end 259 | else 260 | safe_set_param(channel, i, nil, meta_device) 261 | end 262 | end 263 | 264 | if meta_device.fixed_note then 265 | params:set(string.format("midi_device_params_channel_%d_2", channel.number or 0), meta_device.fixed_note) 266 | end 267 | 268 | channel_edit_page_ui.refresh_trig_lock_values() 269 | end 270 | 271 | 272 | 273 | return param_manager 274 | --------------------------------------------------------------------------------