Skip to content

Instantly share code, notes, and snippets.

@cfdrake
Created February 9, 2021 17:25
Show Gist options
  • Save cfdrake/932f7099cf8ab6f932272f5956ec3167 to your computer and use it in GitHub Desktop.
Save cfdrake/932f7099cf8ab6f932272f5956ec3167 to your computer and use it in GitHub Desktop.
-- FM7 Polyphonic Synthesizer
-- With 6 Operator Frequency
-- And wrms
-- Modulation
-- ///////////////////////////
-- key 2: random phase mods
-- key 3: play a random note
-- ///////////////////////////
-- grid pattern player:
-- 1-16 1 high voice
-- 1-16 8 low voice
-- 16 2 pattern record toggle
-- 16 3 pattern play toggle
-- 16 7 pattern transpose mode
-- ///////////////////////////
-- 1-6 2-7 phase mod matrix
-- 8 2-7 operator audio output
-- 10 2-7 frequency multiplier
-- (enables encoder control)
-- ENC1 coarse, ENC2 fine
-- ///////////////////////////
-- Arc encoders are assigned
-- when phase mod toggled.
-- Without an arc, ENC3 is
-- phase mod controller
-- ///////////////////////////
-- edited by: @cfd90
wrms = include 'wrms/wrms'
show_wrms = false
engine.name = 'FM7'
tau = math.pi * 2
-- table to hold tuples to map phase mod params to grid key
-- {grid index, encoder index, parameter name}
arc_mapping = {{0,0,"none"},{0,0,"none"},{0,0,"none"},{0,0,"none"}}
enc_mapping = {true,false,false} -- table to hold parameter names for Norns encoders
g = grid.connect()
a = arc.connect()
-- require params library, why is this local to ~/dust/code and not . ?
local FM7 = include('fm7/lib/fm7')
-- helpers to work with tables
local tab = require 'tabutil'
-- helpers to record and playback patterns on a grid
local pattern_time = require 'pattern_time'
-- helpers for OLED screen
local UI = require 'ui'
-- helpers for MIDI to Hz and scales
local MusicUtil = require "musicutil"
-- pattern player has a transpose mode
local mode_transpose = 0
-- Tables to define the root note and tranposed note (may not be useful right now)
local root = { x=5, y=5 }
local trans = { x=5, y=5 }
-- table of which LEDs on the grid are lit up in the pattern
local lit = {}
-- top right button to start drawing our phase mod grid
local start_pos = {1,2}
-- size of the phase mode grid
local size = {6,6}
-- how many voices allowed for our synth
local MAX_NUM_VOICES = 16
-- current count of active voices
local nvoices = 0
-- table of which phase mod LEDs are toggled on or off
local toggles = {}
-- defaults for phase, frequency and amplitude (i think this is used for the grid OLED page)
local ph_position,hz_position,amp_position = 0,0,0
-- tables for which boxes are selected (this can probably be merged with the toggles table)
local selected = {}
-- table of modulator values (this also might be obsolete by just grabbing the values of the params)
local mods = {}
-- values of which ops output audio
local carriers = {}
-- counter of how many keys in the phase mod grid have been toggled on
local phase_keys_pressed = 0
-- maximum phase mod toggles allowed
local phase_max_keys = 1
-- update the screen at 15 Hz
local screen_framerate = 15
-- a variable for our OLED refresh metronome
local screen_refresh_metro
-- pythagorean minor/major, kinda
local ratios = { 1, 9/8, 6/5, 5/4, 4/3, 3/2, 27/16, 16/9 }
local base = 27.5 -- low A
-- helper functions
local function getHz(deg,oct)
return base * ratios[deg] * (2^oct)
end
local function getHzET(note)
hz = 55*2^(note/12)
return hz
end
local function grid_vector(x,y)
-- translate x,y coordinates into a vector
return (x-start_pos[1]+1) + ((y-start_pos[2]) * size[1])
end
local function get_toggles_value(x,y)
-- getter for toggles table
idx = grid_vector(x,y)
return toggles[idx]
end
local function set_toggles_value(x,y,val)
-- setter for toggles table
idx = grid_vector(x,y)
toggles[idx] = val
end
local function bool_to_int(value)
-- lua doesn't have a type system that understands integers as bolleans
return value and 1 or 0
end
-- FM parameter grid drawing functions
local function draw_phase_matrix()
for y = start_pos[2], (start_pos[2] + size[2] - 1) do
for x = start_pos[1],(start_pos[1] + size[1] - 1) do
g:led(x,y,3)
end
end
end
local function draw_output_vector()
local x = start_pos[1] + size[1] + 1
local y = start_pos[2]
for i = y,size[2]+1 do
g:led(x,i,10)
end
end
local function draw_frequency_vector()
local x = start_pos[1] + size[1] + 3
local y = start_pos[2]
for i = y,size[2]+1 do
g:led(x,i,3)
end
end
-- arc helper functions
local function assign_next_arc_enc()
enc = 0
for i=1,4 do
if arc_mapping[i][2] == 0 then
arc_mapping[i][2] = i
enc = i
break
end
end
return enc
end
local function remove_arc_enc(x,y)
vec = grid_vector(x,y)
for i=1,4 do
if vec == arc_mapping[i][1] then
a:segment(arc_mapping[i][2],0,tau,0)
arc_mapping[i] = {0,0,"none"}
end
end
end
local function arc_encoder_is_assigned(n)
result = false
for i=1,4 do
if arc_mapping[i][2] == n then
result = true
end
end
return result
end
-- Control the state of the phase modulation grid, get/set toggles, assign arc encoder,
-- draw modulation parameter on arc LED ring, light/dim grid LED
local function grid_phase_state(x,y,z)
local op_out = x - start_pos[1]+1
local op_in = y - start_pos[2]+1
local toggle = get_toggles_value(x,y)
if z == 1 then
toggle = not toggle
set_toggles_value(x,y,toggle)
if toggle then
if a.device then
local arc_enc = assign_next_arc_enc()
arc_mapping[arc_enc] = {grid_vector(x,y),arc_enc,"hz"..op_out.."_to_hz"..op_in}
a:segment(arc_mapping[arc_enc][2],0,params:get(arc_mapping[arc_enc][3]),12)
enc_mapping[3] = false
else
enc_mapping[3] = "hz"..op_out.."_to_hz"..op_in
end
else
remove_arc_enc(x,y)
enc_mapping[3] = false
end
local s = bool_to_int(toggle)
g:led(x,y,3+s*9)
end
end
-- control the state of the output vector, using the carriers table
local function output_vector_state(x,y,z)
idx = y - 1
if carriers[idx] ~= 1 then
carriers[idx] = 1
else
carriers[idx] = 0
end
params:set("carrier"..idx, carriers[idx])
g:led(x,y,3+carriers[idx]*9)
end
-- enable a momentary switch to enable encoder 2 to control the frequency ratio
-- for this operator.
-- TODO: add toggles and limit to one at a time
local function frequency_vector_state(x,y,z)
if z == 1 then
enc_mapping[2] = "hz"..y - start_pos[2] + 1
else
enc_mapping[2] = false
end
--tab.print(enc_mapping)
g:led(x,y,3+z*12)
end
-- callback function for key presses on the grid
function g.key(x,y,z)
local e = {}
e.id = x*8 + y
e.x = x
e.y = y
e.state = z
grid_note(e)
g:refresh()
a:refresh()
pattern_control(x,y,z)
end
-- Control parameters when an encoder (arc or norns) is moved.
-- Draw rounded up value to OLED grid, as a visual indicator for the user.
-- This function could be split up a bit.
local function update_phase_matrix(n,d)
if arc_encoder_is_assigned(n) then
params:delta(arc_mapping[n][3], d/10)
local val = params:get(arc_mapping[n][3])
a:segment(n,0,val,12)
local screen_val = math.ceil(val)
local x = (arc_mapping[n][1] % size[1]) == 0 and size[1] or arc_mapping[n][1] % size[1]
local y = math.ceil(arc_mapping[n][1] / size[2])
mods[x][y] = screen_val
redraw()
a:refresh()
elseif enc_mapping[3] then
params:delta(enc_mapping[3],d/2)
local screen_val = math.ceil(params:get(enc_mapping[3]))
-- this is a hack to get the first phase mod grid key,
-- because when there is no arc, we are limited to 1 encoder
local idx = tab.key(toggles,true)
local x = (idx % size[1]) == 0 and size[1] or idx % size[1]
local y = math.ceil(idx / size[2])
mods[x][y] = screen_val
redraw()
end
end
-- callback function when arc encoder is turned
function a.delta(n,d)
if n == 1 then
update_phase_matrix(n,d)
elseif n == 2 then
update_phase_matrix(n,d)
elseif n == 3 then
update_phase_matrix(n,d)
elseif n == 4 then
update_phase_matrix(n,d)
end
end
function init()
-- connect to first MIDI device, set callback function
m = midi.connect()
m.event = midi_event
-- create a new pattern_time object, set callback function
pat = pattern_time.new()
pat.process = grid_note_trans
-- set amplitude to 0.05, stop everything at init
engine.amp(0.05)
engine.stopAll()
-- load all parameters from included library
FM7.add_params()
-- if a grid is attached, initialize our grid
if g then
draw_phase_matrix()
draw_output_vector()
draw_frequency_vector()
gridredraw()
end
-- if we have an arc, set max grid mod toggles to 4
if a.device then phase_max_keys = 4 end
-- make a screen refresh metronome, set a callback function
screen_refresh_metro = metro.init()
screen_refresh_metro.event = function(stage)
redraw()
end
-- start the metro at 15 Hz
screen_refresh_metro:start(1 / screen_framerate)
-- initialize the OLED screen with phase mod values, also carrier values
-- This should be refactored
for m = 1,6 do
selected[m] = {}
mods[m] = {}
carriers[m] = 1
for n = 1,6 do
selected[m][n] = 0
mods[m][n] = 0
end
end
-- TODO: what are these variables?
-- a light?
light = 0
-- fill up our toggle table with false values
for i=1,6*6 do
table.insert(toggles,false)
end
-- make a new pages collection, with a single page, starting on the first page
pages = UI.Pages.new(1, 1)
params:add_separator()
wrms.init()
redraw()
end
-- copy paste from @tehn earthsea library
function pattern_control(x, y, z)
if x == 16 and y > 1 and y < 8 then
if z == 1 then
if y == 2 and pat.rec == 0 then
mode_transpose = 0
trans.x = 5
trans.y = 5
pat:stop()
engine.stopAll()
pat:clear()
pat:rec_start()
elseif y == 2 and pat.rec == 1 then
pat:rec_stop()
if pat.count > 0 then
root.x = pat.event[1].x
root.y = pat.event[1].y
trans.x = root.x
trans.y = root.y
pat:start()
end
elseif y == 3 and pat.play == 0 and pat.count > 0 then
if pat.rec == 1 then
pat:rec_stop()
end
pat:start()
elseif y == 3 and pat.play == 1 then
pat:stop()
engine.stopAll()
nvoices = 0
lit = {}
elseif y == 7 then
mode_transpose = 1 - mode_transpose
end
end
-- catch key events outside the control row
elseif y < 2 or y > 7 then
if mode_transpose == 0 then
local e = {}
e.id = x*8 + y
e.x = x
e.y = y
e.state = z
pat:watch(e)
grid_note(e)
else
trans.x = x
trans.y = y
end
end
gridredraw()
end
function grid_note(e)
local note = ((7-e.y)*5) + e.x
if e.state > 0 then
if nvoices < MAX_NUM_VOICES then
engine.start(e.id, getHzET(note))
lit[e.id] = {}
lit[e.id].x = e.x
lit[e.id].y = e.y
nvoices = nvoices + 1
end
else
if lit[e.id] ~= nil then
engine.stop(e.id)
lit[e.id] = nil
nvoices = nvoices - 1
end
end
gridredraw()
end
function grid_note_trans(e)
local note = ((7-e.y+(root.y-trans.y))*5) + e.x + (trans.x-root.x)
if e.state > 0 then
if nvoices < MAX_NUM_VOICES then
engine.start(e.id, getHzET(note))
lit[e.id] = {}
lit[e.id].x = e.x + trans.x - root.x
lit[e.id].y = e.y + trans.y - root.y
nvoices = nvoices + 1
end
else
engine.stop(e.id)
lit[e.id] = nil
nvoices = nvoices - 1
end
gridredraw()
end
function gridredraw()
g:all(0)
g:led(1,1,2 + pat.rec * 10)
g:led(1,2,2 + pat.play * 10)
g:led(1,8,2 + mode_transpose * 10)
if mode_transpose == 1 then g:led(trans.x, trans.y, 4) end
for i,e in pairs(lit) do
g:led(e.x, e.y,15)
end
g:refresh()
end
-- OLED drawing function for phase mod page
local function draw_matrix_outputs()
for m = 1,6 do
for n = 1,6 do
screen.rect(m*9, n*9, 9, 9)
l = 2
if selected[m][n] == 1 then
l = l + 3 + light
end
screen.level(l)
screen.move_rel(2, 6)
screen.text(math.ceil(mods[m][n]))
screen.stroke()
end
end
for m = 1,6 do
screen.rect(75,m*9,9,9)
screen.move_rel(2, 6)
screen.text(carriers[m])
screen.rect(95,m*9,24,9)
screen.move_rel(2, 6)
screen.text(params:get("hz"..m))
screen.stroke()
end
end
-- callbacks for norns encoders
function enc(n,delta)
if show_wrms then
wrms.enc(n, delta)
else
if enc_mapping[2] then
if n == 1 then
params:delta(enc_mapping[2],delta/8)
draw_matrix_outputs()
elseif n == 2 then
params:delta(enc_mapping[2],delta/16)
draw_matrix_outputs()
end
elseif enc_mapping[3] and n == 3 then
update_phase_matrix(n,delta)
end
end
end
-- function to set random settings when key 2 is pressed
-- TODO: this is broken
local function set_random_phase_mods(n)
-- clear selected
for x = 1,6 do
for y = 1,6 do
selected[x][y] = 0
mods[x][y] = 0
params:set("hz"..x.."_to_hz"..y,mods[x][y])
g:led(x,y+1,3)
end
end
-- choose new random mods
for i = 1,n do
x = math.random(6)
y = math.random(6)
selected[x][y] = 1
mods[x][y] = math.random()*tau
params:set("hz"..x.."_to_hz"..y,mods[x][y])
grid_phase_state(x,y+1,1)
end
end
-- callback for norns key presses
function key(n,z)
if n == 1 and z == 1 then
show_wrms = not show_wrms
end
if show_wrms then
wrms.key(n, z)
else
if n == 2 and z== 1 then
set_random_phase_mods(4)
redraw()
gridredraw()
end
if n == 3 then
-- grid is used for playing, we're deleting the random note feature
end
end
end
-- callback to redraw the OLED
function redraw()
if show_wrms then
wrms.redraw()
else
screen.clear()
pages:redraw()
draw_matrix_outputs()
--[[
if pages.index == 1 then
draw_matrix_outputs()
else
-- this has been moved to lib/
draw_algo(pages.index - 1)
end
--]]
screen.update()
end
end
-- note on/off functions for synth engine
-- TODO: pass velocity value to engine amplitude
local function note_on(note, vel)
if nvoices < MAX_NUM_VOICES then
--engine.start(id, getHz(x, y-1))
engine.start(note, MusicUtil.note_num_to_freq(note))
nvoices = nvoices + 1
end
end
local function note_off(note, vel)
engine.stop(note)
nvoices = nvoices - 1
end
-- callback function for MIDI events
function midi_event(data)
if #data == 0 then return end
local msg = midi.to_msg(data)
-- Note off
if msg.type == "note_off" then
note_off(msg.note)
-- Note on
elseif msg.type == "note_on" then
note_on(msg.note, msg.vel / 127)
--[[
-- Key pressure
elseif msg.type == "key_pressure" then
set_key_pressure(msg.note, msg.val / 127)
-- Channel pressure
elseif msg.type == "channel_pressure" then
set_channel_pressure(msg.val / 127)
-- Pitch bend
elseif msg.type == "pitchbend" then
local bend_st = (util.round(msg.val / 2)) / 8192 * 2 -1 -- Convert to -1 to 1
local bend_range = params:get("bend_range")
set_pitch_bend(bend_st * bend_range)
]]--
end
end
-- callback when script is unloaded
function cleanup()
pat:stop()
pat = nil
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment