Module:Chord edo approximation: Difference between revisions

From Xenharmonic Wiki
Jump to navigation Jump to search
Pailiaq (talk | contribs)
m experimenting with js play button functionality
Pailiaq (talk | contribs)
No edit summary
 
(21 intermediate revisions by the same user not shown)
Line 1: Line 1:
-- EDO Approximations Module
-- Chord EDO Approximations Module
-- Calculates EDO approximations for just intervals
-- Calculates EDO approximations for JI chords like 4:5:6 or 4:5:6:7
-- Usage: {{#invoke:EDO_Approximations|main|interval=3/2|tolerance=9|min_edo=5|max_edo=60}}
-- 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 u = require("Module:Utils")
local yesno = require("Module:Yesno")
local yesno = require("Module:Yesno")
Line 7: Line 9:


-- ===== CONFIGURATION VARIABLES =====
-- ===== CONFIGURATION VARIABLES =====
local DEFAULT_TOLERANCE = 9.0  -- Relative error tolerance in percent
local DEFAULT_MAX_RMS = 15  -- Max RMS error in % of EDO step
local DEFAULT_MIN_EDO = 5     -- Minimum EDO to check
local DEFAULT_MIN_EDO = 5
local DEFAULT_MAX_EDO = 60     -- Maximum EDO to check
local DEFAULT_MAX_EDO = 60
-- ====================================
-- ====================================


-- Convert a frequency ratio to cents
local function cents(ratio)
local function cents(ratio)
     return 1200 * u.log2(ratio)
     return 1200 * u.log2(ratio)
end
end


-- Python-compatible rounding function (banker's rounding)
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 26:
         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
            return floor_x
        else
            return floor_x + 1
        end
     end
     end
end
end


-- Find the best approximation of an interval in a given EDO
local function gcd(a, b)
local function find_best_approximation(ratio_cents, edo)
     while b ~= 0 do a, b = b, a % b end
     local edostep = 1200 / edo
     return a
    local best_step = round(ratio_cents / edostep)
end
     local approximation_cents = best_step * edostep
    local absolute_error = approximation_cents - ratio_cents
    local relative_error = (absolute_error / edostep) * 100


     return best_step, absolute_error, relative_error
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


-- Calculate all EDO approximations within tolerance for a given ratio
local function calculate_chord_approximation(interval_cents_list, edo)
local function calculate_edo_approximations(ratio, tolerance, min_edo, max_edo)
     local edostep = 1200 / edo
     local ratio_cents = cents(ratio)
    local steps = {}
     local results = {}
    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


     for edo = min_edo, max_edo do
    local n = #note_errors
        local steps, abs_error, rel_error = find_best_approximation(ratio_cents, edo)
    local sum = 0
     for _, e in ipairs(note_errors) do sum = sum + e end
    local mean = sum / n


        if math.abs(rel_error) <= tolerance then
    local sq_sum = 0
            table.insert(results, {
    for _, e in ipairs(note_errors) do
                edo = edo,
        local d = e - mean
                steps = steps,
        sq_sum = sq_sum + d * d
                abs_error = abs_error,
                rel_error = rel_error
            })
        end
     end
     end
    local rms_cents = math.sqrt(sq_sum / n)
    local rms_relative = (rms_cents / edostep) * 100


     return results
     return {
        steps = steps,
        abs_errors = abs_errors,
        mean_offset = mean,
        rms_cents = rms_cents,
        rms_relative = rms_relative,
    }
end
end


-- Format a number with sign and 2 decimal places
local function format_error(value)
local function format_error(value)
     if value >= 0 then
     if value >= 0 then
Line 76: Line 91:
end
end


-- Main function to generate the wikitable
-- 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("&nbsp;", need) .. s
end
 
function p.main(frame)
function p.main(frame)
     local args = frame.args
     local args = frame.args
     local interval_str = args.interval or args[1]
     local chord_str = args.chord or args[1]
     local interval_name = args.interval_name
     local chord_name = args.chord_name
     local tolerance = tonumber(args.tolerance) or DEFAULT_TOLERANCE
     local max_rms = tonumber(args.max_rms) or DEFAULT_MAX_RMS
     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 interval_str then
     if not chord_str then
         return "Error: No interval specified"
         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 ratio = u.eval_num_arg(interval_str)
     local root = notes[1]
    if not ratio then
    local intervals_cents = {}
         return "Error: Invalid interval format (use format like '3/2')"
    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 = calculate_edo_approximations(ratio, tolerance, min_edo, max_edo)
     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
     if #results == 0 then
         return "No edos found within tolerance of " .. tolerance .. "%"
         return "No edos found within RMS tolerance of " .. max_rms .. "%"
     end
     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 = {}
     local output = {}
     table.insert(output, '{| class="wikitable center-all mw-collapsible sortable"')
     table.insert(output, '{| class="wikitable center-all mw-collapsible sortable"')


     local ratio_cents = cents(ratio)
     local display_name = (chord_name and chord_name ~= "") and chord_name or chord_str
     local display_name = (interval_name and interval_name ~= "") and interval_name or interval_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;RMS&nbsp;rel.&nbsp;error&nbsp;&le;&nbsp;%g%%\'\'</span>',
            intervals_display, max_edo, max_rms))


    table.insert(output, '|+ style="font-size: 105%;" | '
        .. string.format('Edo&nbsp;approximations&nbsp;for&nbsp;%s&nbsp;(%.2f{{c}})<br /><span style="font-size: 0.75em;">\'\'&le;&nbsp;%dedo,&nbsp;relative&nbsp;error&nbsp;&le;&nbsp;%g%%\'\'</span>',
            display_name, ratio_cents, max_edo, tolerance))
     table.insert(output, '|-')
     table.insert(output, '|-')
     table.insert(output, '! class="unsortable" | &nbsp;'
     table.insert(output, '! class="unsortable" | &nbsp;'
                        .. ' !! Edo'
        .. ' !! Edo'
                        .. ' !! class="unsortable" | Step size'
        .. ' !! class="unsortable" | Steps'
                        .. ' !! Cents ([[cent|¢]])'
        .. ' !! class="unsortable" | Cents ([[cent|¢]])'
                        .. ' !! Absolute error ([[cent|¢]])'
        .. ' !! class="unsortable" | Absolute errors ([[cent|¢]])'
                        .. ' !! [[Relative interval error|Relative error]] ([[relative cent|%]])')
        .. ' !! RMS ([[cent|¢]])'
        .. ' !! RMS ([[relative cent|%]])')
 
    -- === PASS 2: emit padded, em-dash-separated cell content ===
    local SEP = '&thinsp;'
 
    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, ",")


    for _, result in ipairs(results) do
         local rms_c = string.format("%.2f", r.rms_cents)
         local edo_link = string.format("[[%dedo|%d]]", result.edo, result.edo)
         local rms_r = string.format("%.2f", r.rms_relative)
        local step_size = string.format("%d\\%d", result.steps, result.edo)
         local approximation_cents = result.steps * (1200 / result.edo)
        local approx_str = string.format("%.2f", approximation_cents)
        local abs_err = format_error(result.abs_error)
        local rel_err = format_error(result.rel_error)


        -- Play button: dyad of root (0) + the interval's step in this edo
        local steps_data = "0," .. tostring(result.steps)
         local play_btn = string.format(
         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>',
             '<span class="edo-chord-play" data-edo="%d" data-steps="%s" title="Play %s in %dedo" role="button" tabindex="0">▶</span>',
             result.edo, steps_data, interval_str, result.edo)
             r.edo, steps_data, chord_str, r.edo)


         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 || %s',
             play_btn, edo_link, step_size, approx_str, abs_err, rel_err))
             play_btn, edo_link, steps_cell, cents_cell, errs_cell, rms_c, rms_r))
     end
     end



Latest revision as of 01:01, 27 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
-- 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("&nbsp;", 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, ",&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;RMS&nbsp;rel.&nbsp;error&nbsp;&le;&nbsp;%g%%\'\'</span>',
            intervals_display, max_edo, max_rms))

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

    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