Module:Chord edo approximation: Difference between revisions
Jump to navigation
Jump to search
m experimenting with js play button functionality |
Tag: Undo |
||
| Line 1: | Line 1: | ||
-- EDO Approximations Module | -- Chord EDO Approximations Module | ||
-- Calculates EDO approximations for | -- Calculates EDO approximations for JI chords like 4:5:6 or 4:5:6:7 | ||
-- Usage: {{#invoke: | -- Usage: {{#invoke:Chord_EDO_Approximation|main|chord=4:5:6|max_total_error=20|min_edo=5|max_edo=60}} | ||
local u = require("Module:Utils") | local u = require("Module:Utils") | ||
local yesno = require("Module:Yesno") | local yesno = require("Module:Yesno") | ||
| Line 7: | Line 7: | ||
-- ===== CONFIGURATION VARIABLES ===== | -- ===== CONFIGURATION VARIABLES ===== | ||
local | local DEFAULT_MAX_TOTAL_ERROR = 25 -- Max summed RELATIVE error in cents | ||
local DEFAULT_MIN_EDO = 5 | local DEFAULT_MIN_EDO = 5 | ||
local DEFAULT_MAX_EDO = 60 | local DEFAULT_MAX_EDO = 60 | ||
-- ==================================== | -- ==================================== | ||
local function cents(ratio) | local function cents(ratio) | ||
return 1200 * u.log2(ratio) | return 1200 * u.log2(ratio) | ||
end | end | ||
-- Python-compatible | -- Python-compatible round (banker's rounding) — matches your existing module | ||
local function round(x) | local function round(x) | ||
local floor_x = math.floor(x) | local floor_x = math.floor(x) | ||
local frac = x - floor_x | local frac = x - floor_x | ||
if frac < 0.5 then | if frac < 0.5 then | ||
return floor_x | return floor_x | ||
| Line 27: | Line 25: | ||
return floor_x + 1 | return floor_x + 1 | ||
else | else | ||
if floor_x % 2 == 0 then | if floor_x % 2 == 0 then return floor_x else return floor_x + 1 end | ||
end | end | ||
end | end | ||
local function gcd(a, b) | |||
local function | while b ~= 0 do a, b = b, a % b end | ||
return a | |||
end | |||
return | -- Parse "4:5:6" or "4:5:6:7" into a list of positive integers | ||
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 | end | ||
-- | -- For a given EDO and a list of interval-cents (from root), compute the chord approximation | ||
local function | local function calculate_chord_approximation(interval_cents_list, edo) | ||
local | local edostep = 1200 / edo | ||
local | local steps = {} | ||
local abs_errors = {} | |||
local rel_errors = {} | |||
local total_abs = 0 | |||
local total_rel = 0 | |||
for | for _, ic in ipairs(interval_cents_list) do | ||
local | local step = round(ic / edostep) | ||
local approx = step * edostep | |||
local abs_err = approx - ic | |||
local rel_err = (abs_err / edostep) * 100 | |||
table.insert(steps, step) | |||
table.insert(abs_errors, abs_err) | |||
table.insert(rel_errors, rel_err) | |||
total_abs = total_abs + math.abs(abs_err) | |||
total_rel = total_rel + math.abs(rel_err) | |||
end | end | ||
return | return { | ||
steps = steps, | |||
abs_errors = abs_errors, | |||
rel_errors = rel_errors, | |||
total_abs = total_abs, | |||
total_rel = total_rel, | |||
} | |||
end | end | ||
local function format_error(value) | local function format_error(value) | ||
if value >= 0 then | if value >= 0 then | ||
| Line 76: | Line 84: | ||
end | end | ||
function p.main(frame) | function p.main(frame) | ||
local args = frame.args | local args = frame.args | ||
local | local chord_str = args.chord or args[1] | ||
local | local chord_name = args.chord_name | ||
local | local max_total_error = tonumber(args.max_total_error) or DEFAULT_MAX_TOTAL_ERROR | ||
local min_edo = tonumber(args.min_edo) or DEFAULT_MIN_EDO | local min_edo = tonumber(args.min_edo) or DEFAULT_MIN_EDO | ||
local max_edo = tonumber(args.max_edo) or DEFAULT_MAX_EDO | local max_edo = tonumber(args.max_edo) or DEFAULT_MAX_EDO | ||
if not | if not chord_str then | ||
return "Error: No | 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 | end | ||
local | -- Build intervals from root → each upper note | ||
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 | end | ||
local results = | -- Scan EDOs in range (inclusive) | ||
local results = {} | |||
for edo = min_edo, max_edo do | |||
local data = calculate_chord_approximation(intervals_cents, edo) | |||
if data.total_rel <= max_total_error then | |||
data.edo = edo | |||
table.insert(results, data) | |||
end | |||
end | |||
if #results == 0 then | if #results == 0 then | ||
return "No edos found within tolerance of " .. | return "No edos found within total absolute error tolerance of " .. max_total_error .. "¢" | ||
end | end | ||
-- Build wikitable | |||
local output = {} | local output = {} | ||
table.insert(output, '{| class="wikitable center-all mw-collapsible sortable"') | table.insert(output, '{| class="wikitable center-all mw-collapsible sortable"') | ||
local | local display_name = (chord_name and chord_name ~= "") and chord_name or chord_str | ||
local display_name = ( | local intervals_display = table.concat(interval_strs, ", ") | ||
-- Caption | |||
local caption_main | |||
if display_name ~= chord_str then | |||
caption_main = string.format("Edo approximations for %s (%s)", display_name, chord_str) | |||
else | |||
caption_main = string.format("Edo approximations for %s", display_name) | |||
end | |||
table.insert(output, '|+ style="font-size: 105%;" | ' .. caption_main | |||
.. string.format('<br /><span style="font-size: 0.75em;">\'\'intervals: %s · ≤ %dedo, total abs error ≤ %g{{c}}\'\'</span>', | |||
intervals_display, max_edo, max_total_error)) | |||
table.insert(output, '|-') | table.insert(output, '|-') | ||
table.insert(output, '! | table.insert(output, '! Edo' | ||
.. ' !! class="unsortable" | Steps' | |||
.. ' !! class="unsortable" | Cents ([[cent|¢]])' | |||
.. ' !! class="unsortable" | Absolute errors ([[cent|¢]])' | |||
.. ' !! Total abs. error ([[cent|¢]])' | |||
.. ' !! Total [[Relative interval error|relative error]] ([[relative cent|%]])') | |||
for _, | for _, r in ipairs(results) do | ||
local edo_link = string.format("[[%dedo|%d]]", | local edo_link = string.format("[[%dedo|%d]]", r.edo, r.edo) | ||
local | |||
local | -- Steps column, e.g. "0 4 7\12" | ||
local | local step_parts = {"0"} | ||
local | for _, s in ipairs(r.steps) do | ||
local | table.insert(step_parts, tostring(s)) | ||
end | |||
local steps_str = table.concat(step_parts, " ") | |||
-- Cents approximation column | |||
local edostep = 1200 / r.edo | |||
local cents_parts = {"0.00"} | |||
for _, s in ipairs(r.steps) do | |||
table.insert(cents_parts, string.format("%.2f", s * edostep)) | |||
end | |||
local cents_str = table.concat(cents_parts, " ") | |||
-- Per-interval absolute errors (signed) | |||
local err_parts = {} | |||
for _, e in ipairs(r.abs_errors) do | |||
table.insert(err_parts, format_error(e)) | |||
end | |||
local err_str = table.concat(err_parts, " ") | |||
local total_abs_str = string.format("%.2f", r.total_abs) | |||
local | local total_rel_str = string.format("%.2f", r.total_rel) | ||
local | |||
table.insert(output, '|-') | table.insert(output, '|-') | ||
table.insert(output, string.format('| %s || %s || %s || %s || %s || %s', | table.insert(output, string.format('| %s || %s || %s || %s || %s || %s', | ||
edo_link, steps_str, cents_str, err_str, total_abs_str, total_rel_str)) | |||
end | end | ||
Revision as of 01:29, 26 May 2026
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
-- Usage: {{#invoke:Chord_EDO_Approximation|main|chord=4:5:6|max_total_error=20|min_edo=5|max_edo=60}}
local u = require("Module:Utils")
local yesno = require("Module:Yesno")
local p = {}
-- ===== CONFIGURATION VARIABLES =====
local DEFAULT_MAX_TOTAL_ERROR = 25 -- Max summed RELATIVE error in cents
local DEFAULT_MIN_EDO = 5
local DEFAULT_MAX_EDO = 60
-- ====================================
local function cents(ratio)
return 1200 * u.log2(ratio)
end
-- Python-compatible round (banker's rounding) — matches your existing module
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
-- Parse "4:5:6" or "4:5:6:7" into a list of positive integers
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
-- For a given EDO and a list of interval-cents (from root), compute the chord approximation
local function calculate_chord_approximation(interval_cents_list, edo)
local edostep = 1200 / edo
local steps = {}
local abs_errors = {}
local rel_errors = {}
local total_abs = 0
local total_rel = 0
for _, ic in ipairs(interval_cents_list) do
local step = round(ic / edostep)
local approx = step * edostep
local abs_err = approx - ic
local rel_err = (abs_err / edostep) * 100
table.insert(steps, step)
table.insert(abs_errors, abs_err)
table.insert(rel_errors, rel_err)
total_abs = total_abs + math.abs(abs_err)
total_rel = total_rel + math.abs(rel_err)
end
return {
steps = steps,
abs_errors = abs_errors,
rel_errors = rel_errors,
total_abs = total_abs,
total_rel = total_rel,
}
end
local function format_error(value)
if value >= 0 then
return string.format("+%.2f", value)
else
return string.format("%.2f", value)
end
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_total_error = tonumber(args.max_total_error) or DEFAULT_MAX_TOTAL_ERROR
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
-- Build intervals from root → each upper note
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
-- Scan EDOs in range (inclusive)
local results = {}
for edo = min_edo, max_edo do
local data = calculate_chord_approximation(intervals_cents, edo)
if data.total_rel <= max_total_error then
data.edo = edo
table.insert(results, data)
end
end
if #results == 0 then
return "No edos found within total absolute error tolerance of " .. max_total_error .. "¢"
end
-- Build wikitable
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, ", ")
-- Caption
local caption_main
if display_name ~= chord_str then
caption_main = string.format("Edo approximations for %s (%s)", display_name, chord_str)
else
caption_main = string.format("Edo approximations for %s", display_name)
end
table.insert(output, '|+ style="font-size: 105%;" | ' .. caption_main
.. string.format('<br /><span style="font-size: 0.75em;">\'\'intervals: %s · ≤ %dedo, total abs error ≤ %g{{c}}\'\'</span>',
intervals_display, max_edo, max_total_error))
table.insert(output, '|-')
table.insert(output, '! Edo'
.. ' !! class="unsortable" | Steps'
.. ' !! class="unsortable" | Cents ([[cent|¢]])'
.. ' !! class="unsortable" | Absolute errors ([[cent|¢]])'
.. ' !! Total abs. error ([[cent|¢]])'
.. ' !! Total [[Relative interval error|relative error]] ([[relative cent|%]])')
for _, r in ipairs(results) do
local edo_link = string.format("[[%dedo|%d]]", r.edo, r.edo)
-- Steps column, e.g. "0 4 7\12"
local step_parts = {"0"}
for _, s in ipairs(r.steps) do
table.insert(step_parts, tostring(s))
end
local steps_str = table.concat(step_parts, " ")
-- Cents approximation column
local edostep = 1200 / r.edo
local cents_parts = {"0.00"}
for _, s in ipairs(r.steps) do
table.insert(cents_parts, string.format("%.2f", s * edostep))
end
local cents_str = table.concat(cents_parts, " ")
-- Per-interval absolute errors (signed)
local err_parts = {}
for _, e in ipairs(r.abs_errors) do
table.insert(err_parts, format_error(e))
end
local err_str = table.concat(err_parts, " ")
local total_abs_str = string.format("%.2f", r.total_abs)
local total_rel_str = string.format("%.2f", r.total_rel)
table.insert(output, '|-')
table.insert(output, string.format('| %s || %s || %s || %s || %s || %s',
edo_link, steps_str, cents_str, err_str, total_abs_str, total_rel_str))
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