Module:Chord edo approximation
Jump to navigation
Jump to search
Documentation for this module may be created at Module:Chord edo approximation/doc
-- Chord EDO Approximations Module
-- Calculates EDO approximations for JI chords like 4:5:6 or 4:5:6:7
-- Metric: RMS of per-note errors around the optimal reference (least-squares),
-- expressed as % of EDO step (relative) so smaller EDOs aren't penalized for
-- their inherently coarser resolution.
local u = require("Module:Utils")
local yesno = require("Module:Yesno")
local p = {}
-- ===== CONFIGURATION VARIABLES =====
local DEFAULT_MAX_RMS = 15 -- Max RMS error in % of EDO step
local DEFAULT_MIN_EDO = 5
local DEFAULT_MAX_EDO = 60
-- ====================================
local function cents(ratio)
return 1200 * u.log2(ratio)
end
local function round(x)
local floor_x = math.floor(x)
local frac = x - floor_x
if frac < 0.5 then
return floor_x
elseif frac > 0.5 then
return floor_x + 1
else
if floor_x % 2 == 0 then return floor_x else return floor_x + 1 end
end
end
local function gcd(a, b)
while b ~= 0 do a, b = b, a % b end
return a
end
local function parse_chord(chord_str)
local notes = {}
for n in string.gmatch(chord_str, "([^:%s]+)") do
local num = tonumber(n)
if not num or num <= 0 then return nil end
table.insert(notes, num)
end
if #notes < 2 then return nil end
return notes
end
local function calculate_chord_approximation(interval_cents_list, edo)
local edostep = 1200 / edo
local steps = {}
local abs_errors = {}
local note_errors = {0}
for _, ic in ipairs(interval_cents_list) do
local step = round(ic / edostep)
local approx = step * edostep
local abs_err = approx - ic
table.insert(steps, step)
table.insert(abs_errors, abs_err)
table.insert(note_errors, abs_err)
end
local n = #note_errors
local sum = 0
for _, e in ipairs(note_errors) do sum = sum + e end
local mean = sum / n
local sq_sum = 0
for _, e in ipairs(note_errors) do
local d = e - mean
sq_sum = sq_sum + d * d
end
local rms_cents = math.sqrt(sq_sum / n)
local rms_relative = (rms_cents / edostep) * 100
return {
steps = steps,
abs_errors = abs_errors,
mean_offset = mean,
rms_cents = rms_cents,
rms_relative = rms_relative,
}
end
local function format_error(value)
if value >= 0 then
return string.format("+%.2f", value)
else
return string.format("%.2f", value)
end
end
-- Right-align s to width by prepending non-breaking spaces (so HTML preserves them)
local function pad_left(s, width)
local need = width - #s
if need <= 0 then return s end
return string.rep(" ", need) .. s
end
function p.main(frame)
local args = frame.args
local chord_str = args.chord or args[1]
local chord_name = args.chord_name
local max_rms = tonumber(args.max_rms) or DEFAULT_MAX_RMS
local min_edo = tonumber(args.min_edo) or DEFAULT_MIN_EDO
local max_edo = tonumber(args.max_edo) or DEFAULT_MAX_EDO
if not chord_str then
return "Error: No chord specified"
end
local notes = parse_chord(chord_str)
if not notes then
return "Error: Invalid chord format (use 'a:b:c' with positive integers, e.g. '4:5:6')"
end
local root = notes[1]
local intervals_cents = {}
local interval_strs = {}
for i = 2, #notes do
local n, d = notes[i], root
local g = gcd(n, d)
local rn, rd = n / g, d / g
table.insert(intervals_cents, cents(n / d))
table.insert(interval_strs, string.format("%d/%d", rn, rd))
end
local results = {}
for edo = min_edo, max_edo do
local data = calculate_chord_approximation(intervals_cents, edo)
if data.rms_relative <= max_rms then
data.edo = edo
table.insert(results, data)
end
end
if #results == 0 then
return "No edos found within RMS tolerance of " .. max_rms .. "%"
end
-- === PASS 1: precompute all formatted strings, find max width per position ===
local n_pos = #(results[1].steps) + 1 -- +1 for the root
local max_step_w = {}
local max_cents_w = {}
local max_err_w = {}
for i = 1, n_pos do max_step_w[i] = 0; max_cents_w[i] = 0; max_err_w[i] = 0 end
for _, r in ipairs(results) do
local edostep = 1200 / r.edo
local step_strs = {"0"}
local cents_strs = {"0.00"}
local err_strs = {"0.00"}
for i, s in ipairs(r.steps) do
table.insert(step_strs, tostring(s))
table.insert(cents_strs, string.format("%.2f", s * edostep))
table.insert(err_strs, format_error(r.abs_errors[i]))
end
for i = 1, n_pos do
if #step_strs[i] > max_step_w[i] then max_step_w[i] = #step_strs[i] end
if #cents_strs[i] > max_cents_w[i] then max_cents_w[i] = #cents_strs[i] end
if #err_strs[i] > max_err_w[i] then max_err_w[i] = #err_strs[i] end
end
r._step_strs = step_strs
r._cents_strs = cents_strs
r._err_strs = err_strs
end
-- JI play button for the caption
local ji_cents_parts = {"0"}
for _, c in ipairs(intervals_cents) do
table.insert(ji_cents_parts, string.format("%.4f", c))
end
local ji_cents_data = table.concat(ji_cents_parts, ",")
local ji_play_btn = string.format(
'<span class="edo-chord-play ji" data-cents="%s" title="Play %s in just intonation" role="button" tabindex="0">▶</span>',
ji_cents_data, chord_str)
local output = {}
table.insert(output, '{| class="wikitable center-all mw-collapsible sortable"')
local display_name = (chord_name and chord_name ~= "") and chord_name or chord_str
local intervals_display = table.concat(interval_strs, ", ")
local caption_main
if display_name ~= chord_str then
caption_main = string.format("Edo approximations for %s (%s) %s", display_name, chord_str, ji_play_btn)
else
caption_main = string.format("Edo approximations for %s %s", display_name, ji_play_btn)
end
table.insert(output, '|+ style="font-size: 105%;" | ' .. caption_main
.. string.format('<br /><span style="font-size: 0.75em;">\'\'intervals: %s · ≤ %dedo, RMS rel. error ≤ %g%%\'\'</span>',
intervals_display, max_edo, max_rms))
table.insert(output, '|-')
table.insert(output, '! class="unsortable" | '
.. ' !! Edo'
.. ' !! class="unsortable" | Steps'
.. ' !! class="unsortable" | Cents ([[cent|¢]])'
.. ' !! class="unsortable" | Absolute errors ([[cent|¢]])'
.. ' !! RMS ([[cent|¢]])'
.. ' !! RMS ([[relative cent|%]])')
-- === PASS 2: emit padded, em-dash-separated cell content ===
local SEP = ' '
for _, r in ipairs(results) do
local edo_link = string.format("[[%dedo|%d]]", r.edo, r.edo)
local padded_steps, padded_cents, padded_errs = {}, {}, {}
for i = 1, n_pos do
table.insert(padded_steps, pad_left(r._step_strs[i], max_step_w[i]))
table.insert(padded_cents, pad_left(r._cents_strs[i], max_cents_w[i]))
table.insert(padded_errs, pad_left(r._err_strs[i], max_err_w[i]))
end
local CODE_STYLE = ' style="padding:2px 3px;white-space:nowrap;"'
local steps_cell = '<code' .. CODE_STYLE .. '>' .. table.concat(padded_steps, SEP) .. '</code>'
local cents_cell = '<code' .. CODE_STYLE .. '>' .. table.concat(padded_cents, SEP) .. '</code>'
local errs_cell = '<code' .. CODE_STYLE .. '>' .. table.concat(padded_errs, SEP) .. '</code>'
-- Steps data attribute for the play button (unpadded, comma-separated)
local steps_data_parts = {"0"}
for _, s in ipairs(r.steps) do table.insert(steps_data_parts, tostring(s)) end
local steps_data = table.concat(steps_data_parts, ",")
local rms_c = string.format("%.2f", r.rms_cents)
local rms_r = string.format("%.2f", r.rms_relative)
local play_btn = string.format(
'<span class="edo-chord-play" data-edo="%d" data-steps="%s" title="Play %s in %dedo" role="button" tabindex="0">▶</span>',
r.edo, steps_data, chord_str, r.edo)
table.insert(output, '|-')
table.insert(output, string.format('| %s || %s || %s || %s || %s || %s || %s',
play_btn, edo_link, steps_cell, cents_cell, errs_cell, rms_c, rms_r))
end
table.insert(output, '|}')
local result = table.concat(output, '\n')
if yesno(frame.args["debug"]) == true then
result = '<syntaxhighlight lang="wikitext">' .. result .. '</syntaxhighlight>'
end
return frame:preprocess(result)
end
return p