Skip to content

Instantly share code, notes, and snippets.

@jiaaro
Created August 14, 2023 21:49
Show Gist options
  • Save jiaaro/56085d9b33e5893de7b5c03a4ac0f01d to your computer and use it in GitHub Desktop.
Save jiaaro/56085d9b33e5893de7b5c03a4ac0f01d to your computer and use it in GitHub Desktop.
Magic Musicbox v 1.2
--[[
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