Module:Chord edo approximation

From Xenharmonic Reference

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_ERROR_BASE = 10        -- Constant tolerance offset (%)
local DEFAULT_ERROR_QUADRATIC = 2.5  -- Multiplier on n·(n+1), where n = number of intervals
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 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)
    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

	if not max_total_error then
	    local n = #notes - 1
	    max_total_error = DEFAULT_ERROR_QUADRATIC * n * (n + 1) + DEFAULT_ERROR_BASE
	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.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 relative error tolerance of " .. max_total_error .. "%"
    end

    -- Build JI play button: raw cents, no EDO involved
    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, ",&nbsp;")

    local caption_main
    if display_name ~= chord_str then
        caption_main = string.format("Edo&nbsp;approximations&nbsp;for&nbsp;%s&nbsp;(%s)&nbsp;%s", display_name, chord_str, ji_play_btn)
    else
        caption_main = string.format("Edo&nbsp;approximations&nbsp;for&nbsp;%s&nbsp;%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:&nbsp;%s&nbsp;·&nbsp;&le;&nbsp;%dedo,&nbsp;total&nbsp;rel&nbsp;error&nbsp;&le;&nbsp;%g%%\'\'</span>',
            intervals_display, max_edo, max_total_error))

    table.insert(output, '|-')
    table.insert(output, '! class="unsortable" | &nbsp;'
        .. ' !! 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)

        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, " ")
        local steps_data = table.concat(step_parts, ",")

        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, " ")

        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)

        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_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