Module:Chord edo approximation: Difference between revisions

Pailiaq (talk | contribs)
No edit summary
Pailiaq (talk | contribs)
No edit summary
 
(8 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
-- Metric: RMS of per-note errors around the optimal reference (least-squares).
-- Metric: RMS of per-note errors around the optimal reference (least-squares),
-- Usage: {{#invoke:Chord_EDO_Approximation|main|chord=4:5:6|max_rms=10|min_edo=5|max_edo=60}}
-- 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 8: Line 9:


-- ===== CONFIGURATION VARIABLES =====
-- ===== CONFIGURATION VARIABLES =====
local DEFAULT_MAX_RMS = 10   -- Max RMS 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 45: Line 46:
end
end


-- For a given EDO and a list of interval-cents (from root, length N-1),
-- compute the approximation. Note errors include the root (= 0) so the
-- optimal-reference math is symmetric across all N notes.
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 = {}   -- per-interval (root-relative), for display
     local abs_errors = {}
     local note_errors = {0} -- including root = 0, for math
     local note_errors = {0}


     for _, ic in ipairs(interval_cents_list) do
     for _, ic in ipairs(interval_cents_list) do
Line 63: Line 61:
     end
     end


    -- Optimal reference offset: mean of note errors (least-squares optimum)
     local n = #note_errors
     local n = #note_errors
     local sum = 0
     local sum = 0
Line 69: Line 66:
     local mean = sum / n
     local mean = sum / n


    -- RMS of centered errors (deviation from the best-fit transposition)
     local sq_sum = 0
     local sq_sum = 0
     for _, e in ipairs(note_errors) do
     for _, e in ipairs(note_errors) do
Line 75: Line 71:
         sq_sum = sq_sum + d * d
         sq_sum = sq_sum + d * d
     end
     end
     local rms = math.sqrt(sq_sum / n)
     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,
        note_errors = note_errors,
         mean_offset = mean,
         mean_offset = mean,
         rms = rms,
         rms_cents = rms_cents,
        rms_relative = rms_relative,
     }
     }
end
end
Line 92: 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 125: Line 129:
     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.rms <= max_rms then
         if data.rms_relative <= max_rms then
             data.edo = edo
             data.edo = edo
             table.insert(results, data)
             table.insert(results, data)
Line 132: Line 136:


     if #results == 0 then
     if #results == 0 then
         return "No edos found within RMS error tolerance of " .. max_rms .. "¢"
         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
     end


Line 159: Line 190:


     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;RMS&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_rms))
             intervals_display, max_edo, max_rms))


Line 168: Line 199:
         .. ' !! class="unsortable" | Cents ([[cent|¢]])'
         .. ' !! class="unsortable" | Cents ([[cent|¢]])'
         .. ' !! class="unsortable" | Absolute errors ([[cent|¢]])'
         .. ' !! class="unsortable" | Absolute errors ([[cent|¢]])'
         .. ' !! RMS error ([[cent|¢]])')
         .. ' !! RMS ([[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)


         local step_parts = {"0"}
         local padded_steps, padded_cents, padded_errs = {}, {}, {}
         for _, s in ipairs(r.steps) do
         for i = 1, n_pos do
             table.insert(step_parts, tostring(s))
             table.insert(padded_steps, pad_left(r._step_strs[i], max_step_w[i]))
        end
             table.insert(padded_cents, pad_left(r._cents_strs[i], max_cents_w[i]))
        local steps_str = table.concat(step_parts, " ")
            table.insert(padded_errs, pad_left(r._err_strs[i], max_err_w[i]))
        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
         end
         local err_str = table.concat(err_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, ",")


         local rms_str = string.format("%.2f", r.rms)
         local rms_c = string.format("%.2f", r.rms_cents)
        local rms_r = string.format("%.2f", r.rms_relative)


         local play_btn = string.format(
         local play_btn = string.format(
Line 200: Line 231:


         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, steps_str, cents_str, err_str, rms_str))
             play_btn, edo_link, steps_cell, cents_cell, errs_cell, rms_c, rms_r))
     end
     end