Created
August 14, 2023 21:49
-
-
Save jiaaro/56085d9b33e5893de7b5c03a4ac0f01d to your computer and use it in GitHub Desktop.
Magic Musicbox v 1.2
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
--[[ | |
Feature Ideas | |
- different cycle lengths per instrument | |
- Setting: "Crank to play", "very slow", "slow", "medium", "fast" | |
- Setting: set key, scale | |
- Key: C, C#, etc | |
- Scale: Major, Minor, Dorian | |
- change instrument for each track | |
- 3 drum samples, plus pentatonic scale | |
- randomize notes | |
- manually set a note to on/off/accent | |
]] | |
import 'CoreLibs/graphics' | |
import 'CoreLibs/animator' | |
import 'CoreLibs/crank' | |
local snd = playdate.sound | |
local gfx = playdate.graphics | |
local Point = playdate.geometry.point.new | |
-- CONSTANTS | |
local app_mode = "Edit" | |
local grid_pos_x = 72 | |
local grid_pos_y = 30 | |
local grid_w = 16 | |
local grid_h = 10 | |
local grid_cell_size = 20 | |
local scales = { | |
-- major pentatonic | |
{"Maj Pentatonic", {12, 9, 7, 4, 2, 0}}, | |
-- minor pentatonic | |
{"Min Pentatonic", {12, 10, 7, 5, 3, 0}}, | |
-- pyramid of 4ths | |
{"Stack of 4ths", {20, 15, 12, 10, 5, 0}}, | |
} | |
local reset_state | |
local crank_pos | |
local scale_idx | |
local transpose | |
local autoplay = false | |
local autoplay_bpm | |
local grid | |
local synth_type | |
local synth_types = {"Sine", "Saw", "Square", "Triangle"} | |
local bpms = {"80", "100", "120", "140"} | |
local app_modes = {"Edit", "Live"} | |
local note_names = { | |
[0]="C", | |
[1]="C#", | |
[2]="D", | |
[3]="D#", | |
[4]="E", | |
[5]="F", | |
[6]="F#", | |
[7]="G", | |
[8]="G#", | |
[9]="A", | |
[10]="A#", | |
[11]="B" | |
} | |
local line_labels = { | |
"_", | |
"_", | |
"_", | |
"_", | |
"_", | |
"_", | |
"RIDE", | |
"HAT", | |
"SNARE", | |
"KICK", | |
} | |
local cursor_pos = Point(grid_pos_x + grid_cell_size/2, grid_pos_y + grid_cell_size/2) | |
local cursor_r = 0 | |
local cursor_track = 1 | |
local cursor_tick = 1 | |
local cursor_pos_animator = nil | |
local cursor_r_animator = nil | |
local function gridxy_to_point(track, tick) | |
local x = grid_pos_x + (grid_cell_size * (tick-1)) + grid_cell_size / 2 | |
local y = grid_pos_y + (grid_cell_size * (track-1)) + grid_cell_size / 2 | |
return Point(x, y) | |
end | |
local function cursor_r_for_track_tick(track, tick) | |
if grid[track][tick] == 1 then | |
return 9 | |
else | |
return 5 | |
end | |
end | |
local function move_cursor(track, tick) | |
local duration = 80 | |
cursor_track = ((track - 1) % grid_h) + 1 | |
cursor_tick = ((tick - 1) % grid_w) + 1 | |
cursor_pos_animator = gfx.animator.new(duration, cursor_pos, gridxy_to_point(cursor_track, cursor_tick)) | |
cursor_r_animator = gfx.animator.new(duration, cursor_r, cursor_r_for_track_tick(cursor_track, cursor_tick)) | |
end | |
local function update_cursor_pos() | |
if cursor_pos_animator then | |
cursor_pos = cursor_pos_animator:currentValue() | |
end | |
if cursor_r_animator then | |
cursor_r = cursor_r_animator:currentValue() | |
end | |
end | |
local function draw_cursor() | |
if app_mode == 'Edit' then | |
gfx.drawCircleAtPoint(cursor_pos, cursor_r) | |
end | |
end | |
local function change_tick(track, tick) | |
if grid[track][tick] == 1 then | |
grid[track][tick] = 0 | |
else | |
grid[track][tick] = 1 | |
track_synths[track]() | |
end | |
cursor_r_animator = gfx.animator.new(50, cursor_r, cursor_r_for_track_tick(cursor_track, cursor_tick)) | |
end | |
local menu = playdate.getSystemMenu() | |
-- menu:addMenuItem("Reset", function() | |
-- reset_state() | |
-- end) | |
local app_mode_menu_item = menu:addOptionsMenuItem("Mode", app_modes, function(value) | |
app_mode = value | |
cursor_track = 1 | |
cursor_tick = 1 | |
cursor_r = cursor_r_for_track_tick(cursor_track, cursor_tick) | |
end) | |
local bpm_menu_item = menu:addOptionsMenuItem("BPM", bpms, function(value) | |
autoplay_bpm = tonumber(value) | |
end) | |
local synth_type_menu_item = menu:addOptionsMenuItem("Synth", synth_types, function(value) | |
synth_type = value | |
for i = 1, 6 do | |
track_synths[i] = newsynth(i, value) | |
end | |
end) | |
function make_beat_thesholds(downbeat, e_beat, and_beat, a_beat) | |
return { | |
downbeat, e_beat, and_beat, a_beat, | |
downbeat, e_beat, and_beat, a_beat, | |
downbeat, e_beat, and_beat, a_beat, | |
downbeat, e_beat, and_beat, a_beat | |
} | |
end | |
local beat_thresholds = { | |
make_beat_thesholds(0.50, 0.40, 0.42, 0.45), | |
make_beat_thesholds(0.48, 0.41, 0.43, 0.44), | |
make_beat_thesholds(0.46, 0.42, 0.44, 0.43), | |
make_beat_thesholds(0.48, 0.43, 0.45, 0.42), | |
make_beat_thesholds(0.50, 0.44, 0.46, 0.41), | |
make_beat_thesholds(0.53, 0.40, 0.48, 0.40), | |
make_beat_thesholds(0.44, 0.38, 0.44, 0.38), | |
make_beat_thesholds(0.46, 0.44, 0.48, 0.44), | |
{ 0.40, 0.38, 0.48, 0.40, 0.51, 0.38, 0.42, 0.40, | |
0.46, 0.38, 0.48, 0.40, 0.51, 0.38, 0.42, 0.44 }, | |
{ 0.55, 0.39, 0.43, 0.39, 0.52, 0.39, 0.43, 0.39, | |
0.53, 0.39, 0.43, 0.39, 0.52, 0.41, 0.48, 0.39 }, | |
make_beat_thesholds(0.53, 0.39, 0.43, 0.39), | |
} | |
function make_empty_grid() | |
local grid = {} | |
local z = math.random() | |
local combiner_sign = 1 | |
if math.random() < 0.5 then | |
combiner_sign = -1 | |
end | |
for y = 1, grid_h do | |
grid[y] = {} | |
for x = 1, grid_w do | |
grid[y][x] = math.floor( | |
gfx.perlin(x / grid_w, y / grid_h, z*(x+(y*combiner_sign)), grid_w, 7, 0.75) | |
+ beat_thresholds[y][x] | |
) | |
end | |
end | |
return grid | |
end | |
function midiNoteToName(midinote) | |
return note_names[midinote % 12] or "?" | |
end | |
function update_line_labels() | |
for i = 1, #scale[2] do | |
line_labels[i] = midiNoteToName(scale[2][i] + transpose) | |
end | |
end | |
function newsynth(note_idx, type) | |
local wavetypes = { | |
Sine=snd.kWaveSine, | |
Saw=snd.kWaveSawtooth, | |
Square=snd.kWaveSquare, | |
Triangle=snd.kWaveTriangle, | |
} | |
local s = snd.synth.new(wavetypes[type]) | |
s:setVolume(0.1) | |
s:setAttack(0) | |
s:setDecay(0.25) | |
s:setSustain(0.1) | |
s:setRelease(0.65) | |
return function() | |
s:playMIDINote(scale[2][note_idx]+transpose) | |
s:noteOff() | |
end | |
end | |
function drumsynth(path) | |
local sample = snd.sample.new(path) | |
return function() sample:play() end | |
end | |
function save_state() | |
playdate.datastore.write({ | |
transpose=transpose, | |
scale_idx=scale_idx, | |
autoplay_bpm=autoplay_bpm, | |
grid=grid, | |
synth_type=synth_type, | |
crank_pos=crank_pos, | |
app_mode=app_mode | |
}) | |
end | |
function load_state() | |
state = playdate.datastore.read() | |
if state then | |
transpose = state.transpose | |
scale_idx = state.scale_idx | |
autoplay_bpm = state.autoplay_bpm | |
grid = state.grid | |
synth_type = state.synth_type | |
crank_pos = state.crank_pos | |
app_mode = state.app_mode or "Live" | |
else | |
transpose = 12 * 6 | |
scale_idx = 1 | |
autoplay_bpm = 100 | |
grid = make_empty_grid() | |
synth_type = "Sine" | |
app_mode = "Live" | |
crank_pos = 0 | |
end | |
scale = scales[scale_idx] | |
bpm_menu_item:setValue(tostring(autoplay_bpm)) | |
synth_type_menu_item:setValue(synth_type) | |
app_mode_menu_item:setValue(app_mode) | |
cursor_r = cursor_r_for_track_tick(cursor_track, cursor_tick) | |
end | |
function reset_state() | |
playdate.datastore.delete() | |
load_state() | |
for i = 1, 6 do | |
track_synths[i] = newsynth(i, synth_type) | |
end | |
app_mode = "Live" | |
app_mode_menu_item:setValue("Live") | |
update_line_labels() | |
end | |
load_state() | |
track_synths = { | |
newsynth(1, synth_type), | |
newsynth(2, synth_type), | |
newsynth(3, synth_type), | |
newsynth(4, synth_type), | |
newsynth(5, synth_type), | |
newsynth(6, synth_type), | |
drumsynth("drums/cymbal-ride_n"), | |
drumsynth("drums/hh-closed_n"), | |
drumsynth("drums/snare_n"), | |
drumsynth("drums/kick_n"), | |
} | |
playhead_pos = 0.0 -- range: 0.0 - 1.0 | |
last_tick_played = -1 | |
last_tick_played_direction = 1 | |
function playdate.upButtonDown() | |
if app_mode == 'Live' then | |
transpose = math.min(127 - 24, transpose + 1) | |
update_line_labels() | |
elseif app_mode == 'Edit' then | |
move_cursor(cursor_track - 1, cursor_tick) | |
end | |
end | |
function playdate.downButtonDown() | |
if app_mode == 'Live' then | |
transpose = math.max(0, transpose - 1) | |
update_line_labels() | |
elseif app_mode == 'Edit' then | |
move_cursor(cursor_track + 1, cursor_tick) | |
end | |
end | |
function playdate.leftButtonDown() | |
if app_mode == 'Live' then | |
scale_idx = 1 + (scale_idx - 2) % #scales | |
scale = scales[scale_idx] | |
update_line_labels() | |
elseif app_mode == 'Edit' then | |
move_cursor(cursor_track, cursor_tick - 1) | |
end | |
end | |
function playdate.rightButtonDown() | |
if app_mode == 'Live' then | |
scale_idx = (scale_idx % #scales) + 1 | |
scale = scales[scale_idx] | |
update_line_labels() | |
elseif app_mode == 'Edit' then | |
move_cursor(cursor_track, cursor_tick + 1) | |
end | |
end | |
function playdate.AButtonDown() | |
autoplay = not autoplay | |
playdate.resetElapsedTime() | |
end | |
function playdate.BButtonDown() | |
if app_mode == 'Live' then | |
grid = make_empty_grid() | |
cursor_r = cursor_r_for_track_tick(cursor_track, cursor_tick) | |
elseif app_mode == 'Edit' then | |
change_tick(cursor_track, cursor_tick) | |
end | |
end | |
function playdate.gameWillResume() | |
playdate.resetElapsedTime() | |
end | |
function playdate.update() | |
update_cursor_pos() | |
crank_pos += playdate.getCrankChange() | |
if autoplay then | |
elapsed = playdate.getElapsedTime() | |
crank_pos += 360 * autoplay_bpm * playdate.getElapsedTime() / 60 | |
playdate.resetElapsedTime() | |
end | |
playhead_pos = math.floor((crank_pos) * 4 / 360) % 16 | |
local playhead_offset = (((crank_pos) * 4 / 360) % 1.0) | |
local crank_direction = 1 | |
if (playhead_pos < last_tick_played and not (last_tick_played == 15 and playhead_pos == 0)) or (playhead_pos == 15 and last_tick_played == 0) then | |
crank_direction = -1 | |
end | |
-- DRAW GRID | |
gfx.clear(gfx.kColorBlack) | |
gfx.setColor(gfx.kColorWhite) | |
local margin = 5 | |
local dot_h = grid_cell_size - (margin * 2) | |
local dot_w = grid_cell_size - (margin * 2) | |
for x = 1, grid_w do | |
trigger_note = false | |
dot_x_offset = 0 | |
if crank_direction == 1 and (x + 16 - 1) % 16 == playhead_pos then | |
if playhead_offset < 0.5 and last_tick_played ~= playhead_pos then | |
dot_x_offset = math.sin(playhead_offset) * grid_cell_size * 2 / 3 | |
else | |
trigger_note = (last_tick_played ~= playhead_pos) | |
if trigger_note then | |
last_tick_played = playhead_pos | |
last_tick_played_direction = 1 | |
end | |
end | |
elseif crank_direction == -1 and (x+16 - 2) % 16 == playhead_pos then | |
if playhead_offset > 0.5 and last_tick_played ~= playhead_pos then | |
dot_x_offset = 0.5 + (-math.sin(1-playhead_offset)) * grid_cell_size * 2/ 3 | |
else | |
trigger_note = (last_tick_played ~= playhead_pos) | |
if trigger_note then | |
last_tick_played = (playhead_pos+16) % 16 | |
last_tick_played_direction = -1 | |
end | |
end | |
end | |
for y = 1, grid_h do | |
if grid[y][x] == 1 then | |
if trigger_note then | |
synth_fn = track_synths[y] | |
if synth_fn then | |
synth_fn() | |
end | |
end | |
gfx.fillCircleInRect( | |
grid_pos_x + (x-1)*grid_cell_size + margin + dot_x_offset, | |
grid_pos_y + (y-1)*grid_cell_size + margin, | |
dot_w, dot_h | |
) | |
else | |
gfx.drawCircleInRect( | |
grid_pos_x + (x-1)*grid_cell_size + margin + 4, | |
grid_pos_y + (y-1)*grid_cell_size + margin + 4, | |
dot_w - 8 , dot_h - 8 | |
) | |
end | |
end | |
end | |
-- DRAW PLAYHEAD -- | |
local playhead_x = ((playhead_pos + 0.5 + playhead_offset) * grid_cell_size) % (grid_cell_size * 16) | |
playhead_x += grid_pos_x | |
gfx.drawLine(playhead_x, grid_pos_y - 4, playhead_x, 240 - 10) | |
-- DRAW TEXT -- | |
gfx.setFont(gfx.getSystemFont(gfx.font.kVariantBold)) | |
gfx.setImageDrawMode(gfx.kDrawModeFillWhite) | |
-- debug | |
-- gfx.drawText('('..crank_direction .. ') ' .. playhead_pos .. '\nlast: '.. last_tick_played, 0, 0) | |
-- draw scale name | |
local scale_name_img = playdate.graphics.image.new(120, 24, gfx.kColorBlack) | |
gfx.pushContext(scale_name_img) | |
gfx.drawTextAligned(scale[1], scale_name_img.width / 2, 0, kTextAlignment.center) | |
gfx.popContext() | |
gfx.setImageDrawMode(gfx.kDrawModeCopy) | |
scale_name_img:drawRotated(12+scale_name_img.height / 2, grid_pos_y + scale_name_img.width / 2, -90) | |
gfx.setImageDrawMode(gfx.kDrawModeFillWhite) | |
-- track labels | |
for i = 1, grid_h do | |
gfx.drawTextAligned(line_labels[i], grid_pos_x - 8, 1 + grid_pos_y + (i - 1) * grid_cell_size, kTextAlignment.right) | |
end | |
-- bar numbers | |
for i = 1, 4 do | |
gfx.drawTextAligned( | |
tostring(i), | |
grid_pos_x + (i-1)*4*grid_cell_size + grid_cell_size/2, | |
grid_pos_y - grid_cell_size - 4, | |
kTextAlignment.center | |
) | |
end | |
draw_cursor() | |
end | |
update_line_labels() | |
function playdate.gameWillTerminate() | |
save_state() | |
end | |
function playdate.deviceWillSleep() | |
save_state() | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment