-- converts an interval string into a note value
-- an interval string consists of any number of #'s and b's
-- followed by a non-negative scale degree
-- (string) -> (number)
local intvToNote = function (str)
local a, b = str:match "^([#b]*)(%d+)$"
local acc = 0
a:gsub(".", function (c) acc = acc + (c == "#" and 1 or -1) end)
b = tonumber(b)
return acc * 7 + (b * 2 - 1) % 7 - 1
end
-- converts a note value into a MIDI pitch value between 0 and 11 inclusive
-- a note value is simply the number of perfect fifths from C
-- (number) -> (number)
local noteToMIDI = function (x)
return x * 7 % 12
end
-- gets the number of sharps in a note value
-- (number) -> (number)
local noteGetAcc = function (x)
return math.floor((x + 1) / 7)
end
-- converts a note value into a note name with proper accidentals
-- (number) -> (string)
local noteToStr; do
local NAME = {[0] = 'C', 'D', 'E', 'F', 'G', 'A', 'B'}
noteToStr = function (x)
local acc = noteGetAcc(x)
return NAME[x * 4 % 7] .. ("#"):rep(acc) .. ("b"):rep(-acc)
end; end
-- converts a MIDI pitch value into a note name with sharp and octave name
-- (number) -> (string)
local MIDItoStr; do
local NAME = {"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"}
MIDItoStr = function (x)
return NAME[x % 12 + 1] .. math.floor(x / 12)
end; end
local LH_LOWEST = -.25 -- left-hand octave offset
local RH_CENTRE = 0 -- right-hand octave offset
LH_LOWEST = 41 + 12 * LH_LOWEST
RH_CENTRE = 60 + 12 * RH_CENTRE
-- converts a chord over a bass note into a MIDI voicing
-- a chord is a list of interval strings, basic order of chord tones is assumed:
-- root to highest extension, followed by added/suspended tones
-- members of lowest triad must use "0" as interval if omitted (or some other nil value)
-- the value corresponding to key 0 indicates an optional slash bass
-- a voicing consists of MIDI pitch values for the bass and four voices
-- ([string], number) -> (number, [number])
local voicing; do
local map = function (f, t)
local z = {}
for k, v in next, t do z[k] = f(v) end
return z
end
voicing = function (chord, trsp)
chord = map(function (x) return x end, chord) -- clone
if chord[0] == "0" then chord[0] = nil end
for i = #chord, 4, -1 do if chord[i] == "0" then table.remove(chord, i) end end
assert(chord[1] and chord[1] ~= "0", "Missing bass note")
local c = map(intvToNote, chord)
local bass = (c[0] or c[1]) + trsp
-- select notes
local vo = {}
local addVoice; do
local selected = {}
for i, v in pairs(chord) do if v == "0" then selected[i] = true end end
addVoice = function (i, f)
if #vo >= 4 then return end
if not selected[i] and (not f or f(c[i])) then
local n = noteToMIDI(c[i] + trsp)
for _, v in ipairs(vo) do if n == v then return end end -- no duplicates
selected[i] = true; table.insert(vo, n)
end
end; end
local VOICES = #c
-- 1st pass: suspended note
addVoice(2, function (n) return n ~= intvToNote("3") and n ~= intvToNote("b3") end)
-- 2nd pass: altered extensions
for i = 5, VOICES do addVoice(i, function (n) return noteGetAcc(n) ~= 0 end) end
-- 3rd pass: altered fifth
addVoice(3, function (n) return n ~= intvToNote("5") end)
-- 4th pass: other extensions
for i = VOICES, 4, -1 do addVoice(i) end
-- 5th pass: third, fifth
for i = 2, 3 do addVoice(i) end
-- 6th pass: slash bass (optional)
-- if chord[0] then addVoice(0); c[0] = nil end
-- 7th pass: root
addVoice(1)
table.sort(vo)
-- raise bass note to left-hand range
bass = noteToMIDI(bass)
while bass < LH_LOWEST do bass = bass + 12 end
-- use average pitch value for voicing
local avg = 0
for _, v in ipairs(vo) do avg = avg + v end
local COUNT = #vo
while avg < (RH_CENTRE - 12) * COUNT do
avg = avg + 12 * COUNT -- raise notes just below right-hand octave
for i, v in ipairs(vo) do vo[i] = v + 12 end
end
while avg < RH_CENTRE * COUNT do
avg = avg + 12 -- invert voicing until average pitch above right-hand octave
table.insert(vo, table.remove(vo, 1) + 12)
end
-- swap end notes for slightly open voicing
vo[1], vo[math.min(COUNT + 1, 4)] = vo[COUNT] - 12, vo[1] + 12
-- if COUNT == 4 then
-- vo[1], vo[4] = vo[4] - 12, vo[1] + 12
-- else -- double one note
-- local top = avg + vo[1] + 12
-- local down = avg + vo[COUNT] - 12
-- local centre = RH_CENTRE * COUNT
-- if math.abs(down - centre) <= math.abs(top - centre) then
-- table.insert(vo, 1, vo[COUNT] - 12)
-- else
-- table.insert(vo, vo[1] + 12)
-- end
-- end
return bass, vo
end; end
local test = function (chord, name)
-- for i, v in ipairs(chord) do print(i, intvToNote(v), noteToStr(intvToNote(v))) end
for i = -7, 7 do
local lh, rh = voicing(chord, i)
io.write(("%14s"):format(noteToStr(i) .. name ..
(chord[0] and "/" .. noteToStr(i + intvToNote(chord[0])) or "")))
io.write "\t\t\t"
for _, v in ipairs(chord) do if v ~= "0" then
io.write((" %3s"):format(noteToStr(intvToNote(v) + i)))
end end
if chord[0] and chord[0] ~= "0" then
io.write((" / %3s"):format(noteToStr(intvToNote(chord[0]) + i)))
end
io.write "\t\t\t"
io.write((" %3s"):format(MIDItoStr(lh)))
for _, v in ipairs(rh) do io.write((" %3s"):format(MIDItoStr(v))) end
io.write '\n'
end
io.write '\n'
end
test({"1", "3", "#5"}, "+")
test({"1", "b3", "5", [0] = "2"}, "m")
test({"1", "3", "5", [0] = "5"}, "")
test({"1", "4", "5", "b7", "9"}, "9sus")
test({"1", "3", "5", "b7", "b9", "13"}, "7(b9,13)")
test({"1", "0", "5"}, "5")
test({"1", "4", "5", "2"}, "sus42")