Module:Chord edo approximation: Difference between revisions

Pailiaq (talk | contribs)
No edit summary
Pailiaq (talk | contribs)
No edit summary
 
(22 intermediate revisions by the same user not shown)
Line 1: Line 1:
-- Chord EDO Approximations Module
-- Chord EDO Approximations Module
-- Calculates EDO approximations for JI chords like 4:5:6 or 4:5:6:7
-- 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}}
-- 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_MAX_TOTAL_ERROR = 25   -- Max summed RELATIVE error in cents
local DEFAULT_MAX_RMS = 15   -- Max RMS error in % of EDO step
local DEFAULT_MIN_EDO = 5
local DEFAULT_MIN_EDO = 5
local DEFAULT_MAX_EDO = 60
local DEFAULT_MAX_EDO = 60
Line 16: Line 18:
end
end


-- 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)
Line 34: Line 35:
end
end


-- Parse "4:5:6" or "4:5:6:7" into a list of positive integers
local function parse_chord(chord_str)
local function parse_chord(chord_str)
     local notes = {}
     local notes = {}
Line 46: Line 46:
end
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 function calculate_chord_approximation(interval_cents_list, edo)
     local edostep = 1200 / edo
     local edostep = 1200 / edo
     local steps = {}
     local steps = {}
     local abs_errors = {}
     local abs_errors = {}
     local rel_errors = {}
     local note_errors = {0}
    local total_abs = 0
    local total_rel = 0


     for _, ic in ipairs(interval_cents_list) do
     for _, ic in ipairs(interval_cents_list) do
Line 59: Line 56:
         local approx = step * edostep
         local approx = step * edostep
         local abs_err = approx - ic
         local abs_err = approx - ic
        local rel_err = (abs_err / edostep) * 100
         table.insert(steps, step)
         table.insert(steps, step)
         table.insert(abs_errors, abs_err)
         table.insert(abs_errors, abs_err)
         table.insert(rel_errors, rel_err)
         table.insert(note_errors, abs_err)
        total_abs = total_abs + math.abs(abs_err)
        total_rel = total_rel + math.abs(rel_err)
     end
     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 {
     return {
         steps = steps,
         steps = steps,
         abs_errors = abs_errors,
         abs_errors = abs_errors,
         rel_errors = rel_errors,
         mean_offset = mean,
         total_abs = total_abs,
         rms_cents = rms_cents,
         total_rel = total_rel,
         rms_relative = rms_relative,
     }
     }
end
end
Line 82: Line 89:
         return string.format("%.2f", value)
         return string.format("%.2f", value)
     end
     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
end


Line 88: Line 102:
     local chord_str = args.chord or args[1]
     local chord_str = args.chord or args[1]
     local chord_name = args.chord_name
     local chord_name = args.chord_name
     local max_total_error = tonumber(args.max_total_error) or DEFAULT_MAX_TOTAL_ERROR
     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
Line 101: Line 115:
     end
     end


    -- Build intervals from root → each upper note
     local root = notes[1]
     local root = notes[1]
     local intervals_cents = {}
     local intervals_cents = {}
Line 113: Line 126:
     end
     end


    -- Scan EDOs in range (inclusive)
     local results = {}
     local results = {}
     for edo = min_edo, max_edo do
     for edo = min_edo, max_edo do
         local data = calculate_chord_approximation(intervals_cents, edo)
         local data = calculate_chord_approximation(intervals_cents, edo)
         if data.total_rel <= max_total_error then
         if data.rms_relative <= max_rms then
             data.edo = edo
             data.edo = edo
             table.insert(results, data)
             table.insert(results, data)
Line 124: Line 136:


     if #results == 0 then
     if #results == 0 then
         return "No edos found within total absolute error tolerance of " .. max_total_error .. "¢"
         return "No edos found within RMS tolerance of " .. max_rms .. "%"
     end
     end


     -- Build wikitable
     -- === 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"')
Line 134: Line 182:
     local intervals_display = table.concat(interval_strs, ",&nbsp;")
     local intervals_display = table.concat(interval_strs, ",&nbsp;")


    -- Caption
     local caption_main
     local caption_main
     if display_name ~= chord_str then
     if display_name ~= chord_str then
         caption_main = string.format("Edo&nbsp;approximations&nbsp;for&nbsp;%s&nbsp;(%s)", display_name, chord_str)
         caption_main = string.format("Edo&nbsp;approximations&nbsp;for&nbsp;%s&nbsp;(%s)&nbsp;%s", display_name, chord_str, ji_play_btn)
     else
     else
         caption_main = string.format("Edo&nbsp;approximations&nbsp;for&nbsp;%s", display_name)
         caption_main = string.format("Edo&nbsp;approximations&nbsp;for&nbsp;%s&nbsp;%s", display_name, ji_play_btn)
     end
     end


     table.insert(output, '|+ style="font-size: 105%;" | ' .. caption_main
     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;abs&nbsp;error&nbsp;&le;&nbsp;%g{{c}}\'\'</span>',
         .. 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_total_error))
             intervals_display, max_edo, max_rms))


     table.insert(output, '|-')
     table.insert(output, '|-')
     table.insert(output, '! Edo'
     table.insert(output, '! class="unsortable" | &nbsp;'
        .. ' !! Edo'
         .. ' !! class="unsortable" | Steps'
         .. ' !! class="unsortable" | Steps'
         .. ' !! class="unsortable" | Cents ([[cent|¢]])'
         .. ' !! class="unsortable" | Cents ([[cent|¢]])'
         .. ' !! class="unsortable" | Absolute errors ([[cent|¢]])'
         .. ' !! class="unsortable" | Absolute errors ([[cent|¢]])'
         .. ' !! Total abs. error ([[cent|¢]])'
         .. ' !! RMS ([[cent|¢]])'
         .. ' !! Total [[Relative interval error|relative error]] ([[relative cent|%]])')
         .. ' !! RMS ([[relative cent|%]])')
 
    -- === PASS 2: emit padded, em-dash-separated cell content ===
    local SEP = '&thinsp;'


     for _, r in ipairs(results) do
     for _, r in ipairs(results) do
         local edo_link = string.format("[[%dedo|%d]]", r.edo, r.edo)
         local edo_link = string.format("[[%dedo|%d]]", r.edo, r.edo)


         -- Steps column, e.g. "0 4 7\12"
         local padded_steps, padded_cents, padded_errs = {}, {}, {}
        local step_parts = {"0"}
         for i = 1, n_pos do
         for _, s in ipairs(r.steps) do
            table.insert(padded_steps, pad_left(r._step_strs[i], max_step_w[i]))
             table.insert(step_parts, tostring(s))
            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
         end
         local steps_str = table.concat(step_parts, " ")  
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, ",")


        -- Cents approximation column
         local rms_c = string.format("%.2f", r.rms_cents)
         local edostep = 1200 / r.edo
         local rms_r = string.format("%.2f", r.rms_relative)
        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 play_btn = string.format(
        local total_rel_str = string.format("%.2f", r.total_rel)
            '<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, '|-')
         table.insert(output, string.format('| %s || %s || %s || %s || %s || %s',
         table.insert(output, string.format('| %s || %s || %s || %s || %s || %s || %s',
             edo_link, steps_str, cents_str, err_str, total_abs_str, total_rel_str))
             play_btn, edo_link, steps_cell, cents_cell, errs_cell, rms_c, rms_r))
     end
     end