Module:ChordVoicings: Difference between revisions

Eufalesio (talk | contribs)
No edit summary
Eufalesio (talk | contribs)
No edit summary
Line 1: Line 1:
-- Module:ChordVoicings (hardened against CPU limit / signal 24)
-- Module:ChordVoicings (tight caps: ≤ n rotations per column, ≤ 2^(n-1) voicings)
-- Usage: {{ChordVoicingTable|rchord=4:5:6:7}}
-- Usage: {{ChordVoicingTable|rchord=4:5:6:7}}


local p = {}
local p = {}


-- ---------- utilities ----------
-- ---------- small utils ----------
local function split_enum(s)
local function split_enum(s)
     local t = {}
     local t = {}
     for num in string.gmatch(s or "", "%d+") do
     for num in string.gmatch(s or "", "%d+") do t[#t+1] = tonumber(num) end
        t[#t+1] = tonumber(num)
    end
     return t
     return t
end
end
local function gcd(a,b) while b ~= 0 do a, b = a % b, b end; return math.abs(a) end
local function gcd_list(t) local g=t[1]; for i=2,#t do g=gcd(g,t[i]) end; return g end
local function odd_part(n) while n % 2 == 0 do n = math.floor(n/2) end; return n end
local function toodds(enum) local o={}; for i=1,#enum do o[i]=odd_part(enum[i]) end; return o end
local function to_str(t) local p={}; for i=1,#t do p[i]=tostring(t[i]) end; return table.concat(p, ":") end
local function sort_key(t) local p={}; for i=1,#t do p[i]=string.format("%03d", t[i]) end; return table.concat(p,"-") end
local function span_ratio(t) local mn, mx=t[1], t[1]; for i=2,#t do if t[i]<mn then mn=t[i] end; if t[i]>mx then mx=t[i] end end; return mx/mn end
local function clone(t) local u={}; for i=1,#t do u[i]=t[i] end; return u end
local function unique_in_order(t) local seen,out={},{}; for _,x in ipairs(t) do if not seen[x] then seen[x]=true; out[#out+1]=x end end; return out end


local function gcd(a,b)
    while b ~= 0 do a, b = a % b, b end
    return math.abs(a)
end
local function gcd_list(t)
    local g = t[1]
    for i=2,#t do g = gcd(g, t[i]) end
    return g
end
local function odd_part(n)
    while n % 2 == 0 do n = math.floor(n/2) end
    return n
end
local function to_str(t)
    local parts = {}
    for i=1,#t do parts[i]=tostring(t[i]) end
    return table.concat(parts, ":")
end
local function sort_key(t)
    local parts = {}
    for i=1,#t do parts[i]=string.format("%03d", t[i]) end
    return table.concat(parts, "-")
end
local function span_ratio(t)
    local mn, mx = t[1], t[1]
    for i=2,#t do
        if t[i]<mn then mn=t[i] end
        if t[i]>mx then mx=t[i] end
    end
    return mx/mn
end
-- memoized reduction
local reduce_cache = {}
local function key_of(t) return sort_key(t) end
local function clone(t)
    local u = {}
    for i=1,#t do u[i]=t[i] end
    return u
end
local function reduce_irreducible(t)
local function reduce_irreducible(t)
    local k = key_of(t)
    local cached = reduce_cache[k]
    if cached then return cached end
     local g = gcd_list(t)
     local g = gcd_list(t)
    local u
     if g>1 then
     if g>1 then
         u = {}
         local u = {}
         for i=1,#t do u[i]=math.floor(t[i]/g) end
         for i=1,#t do u[i]=math.floor(t[i]/g) end
    else
         return u
         u = clone(t)
     end
     end
    reduce_cache[k] = u
     return clone(t)
     return u
end
end


-- two-octave rotation step
-- one two-octave rotation
local function rot2_once(t)
local function rot2_once(t)
     local n = #t
     local n = #t
Line 81: Line 39:
end
end


local function toodds(enum)
-- generate non-empty subsets (≤ 2^(k)-1) and cap explicitly
    local odds = {}
local function all_nonempty_subsets_capped(arr, maxcount)
    for i=1,#enum do odds[i] = odd_part(enum[i]) end
    return odds
end
 
local function unique_in_order(t)
    local seen, out = {}, {}
    for _,x in ipairs(t) do if not seen[x] then seen[x]=true; out[#out+1]=x end end
    return out
end
 
-- subsets
local function all_nonempty_subsets(arr)
     local res = {}
     local res = {}
     local function rec(i, cur)
     local function rec(i, cur)
        if #res >= maxcount then return end
         if i > #arr then
         if i > #arr then
             if #cur > 0 then
             if #cur > 0 then
Line 105: Line 52:
             return
             return
         end
         end
         rec(i+1, cur)               -- exclude
         if #res < maxcount then rec(i+1, cur) end
         cur[#cur+1] = arr[i]; rec(i+1, cur); cur[#cur] = nil -- include
         if #res < maxcount then
            cur[#cur+1] = arr[i]
            rec(i+1, cur)
            cur[#cur] = nil
        end
     end
     end
     rec(1, {})
     rec(1, {})
Line 112: Line 63:
end
end


local function subset_labels(nonroot_odds)
local function subset_labels(nonroot_odds, n_total)
    -- There are at most 2^(n-1) columns including Root. Non-root subset count ≤ 2^(n-1)-1.
    local k = #nonroot_odds
    local max_subsets = math.max(0, (2^(n_total-1)) - 1)
 
     local nums = {}
     local nums = {}
     for i,x in ipairs(nonroot_odds) do nums[i]=x end
     for i,x in ipairs(nonroot_odds) do nums[i]=x end
Line 120: Line 75:
     labels[#labels+1] = { text="Root", set={} }
     labels[#labels+1] = { text="Root", set={} }


     local subsets = all_nonempty_subsets(nums)
     local subsets = all_nonempty_subsets_capped(nums, max_subsets)
     table.sort(subsets, function(a,b)
     table.sort(subsets, function(a,b)
         if #a ~= #b then return #a < #b end
         if #a ~= #b then return #a < #b end
Line 149: Line 104:
local function bold_italic(s) return "'''''" .. s .. "'''''" end
local function bold_italic(s) return "'''''" .. s .. "'''''" end


-- ---------- hardened rotation cycle with cap ----------
-- ---------- rotation map with hard cap: ≤ n rotations ----------
local function build_rotation_map(start_vec)
-- Build map: firstOdd -> vector, performing at most n steps (including initial state).
-- Stop early if we have covered all requested row-odds.
local function build_rotation_map_capped(start_vec, want_odds_set, n)
     local map = {}
     local map = {}
    local seen = {}
     local v = reduce_irreducible(start_vec)
     local v = reduce_irreducible(start_vec)
     local steps, MAX = 0, 512  -- hard cap to avoid CPU overruns
     for step = 1, n do
 
    while true do
         local fodd = odd_part(v[1])
         local fodd = odd_part(v[1])
         if not map[fodd] then map[fodd] = v end
         if not map[fodd] then
 
            map[fodd] = v
        local sig = key_of(v)
            -- early exit if all wanted odds are covered
        if seen[sig] then break end
            local all_ok = true
        seen[sig] = true
            for od,_ in pairs(want_odds_set) do
 
                if not map[od] then all_ok=false; break end
         local nextv = rot2_once(v)
            end
         steps = steps + 1
            if all_ok then break end
         if steps > MAX then break end
         end
 
         -- last iteration shouldn't rotate again
         v = nextv
         if step == n then break end
         v = rot2_once(v)
     end
     end
     return map
     return map
end
end


-- ---------- main render ----------
-- ---------- main ----------
function p.render(frame)
function p.render(frame)
     local args = frame.args or frame:getParent().args
     local args = frame.args or frame:getParent().args
Line 186: Line 140:
     local cls  = args.class or "wikitable sortable"
     local cls  = args.class or "wikitable sortable"


    -- parse & sanity
     local enum = split_enum(rchord_str)
     local enum = split_enum(rchord_str)
     if #enum < 3 then
     local n = #enum
    if n < 3 then
         return "<strong class=\"error\">rchord must have at least 3 integers</strong>"
         return "<strong class=\"error\">rchord must have at least 3 integers</strong>"
    end
    -- optional global guard for pathological inputs
    if #enum > 7 then
        return "<strong class=\"error\">rchord too large for on-wiki render (max 7 terms)</strong>"
     end
     end


    -- odds & rows
     local odds = toodds(enum)
     local odds = toodds(enum)
     local root_odd = odd_part(enum[1])
     local root_odd = odd_part(enum[1])
     local odd_rows = unique_in_order(odds)
     local odd_rows = unique_in_order(odds)


     -- columns: Root + subsets of non-root odds
    -- which row-odds do we actually need to cover?
    local want_odds_set = {}
    for _,o in ipairs(odd_rows) do want_odds_set[o] = true end
 
     -- columns: Root + subsets of non-root odds, capped at 2^(n-1)
     local nonroot_odds_unique = {}
     local nonroot_odds_unique = {}
     for _,o in ipairs(odd_rows) do
     for _,o in ipairs(odd_rows) do if o ~= root_odd then nonroot_odds_unique[#nonroot_odds_unique+1] = o end end
        if o ~= root_odd then nonroot_odds_unique[#nonroot_odds_unique+1] = o end
     local col_specs = subset_labels(nonroot_odds_unique, n) -- {text,set}
    end
     local col_specs = subset_labels(nonroot_odds_unique) -- {text,set}
 
    -- trivial case: if there are no non-root odds, only the Root column exists
    if #col_specs == 1 then
        local only = reduce_irreducible(enum)
        local out = {}
        out[#out+1] = "== " .. title .. " =="
        out[#out+1] = string.format('{| class="%s" style="text-align:center;"', cls)
        out[#out+1] = "|+"
        out[#out+1] = string.format('! style="width:%s;" {{diagonal split header|Rotation|Voicing}}', width)
        out[#out+1] = string.format('! style="width:%s;" | Root', width)
 
        for _,rowodd in ipairs(odd_rows) do
            out[#out+1] = "|-"
            out[#out+1] = string.format('! style="width:%s;" | On %s', width, tostring(rowodd))
 
            local rotated_map = build_rotation_map(enum)
            local r = rotated_map[rowodd] or reduce_irreducible(enum)
            local disp = to_str(r)
            local shown = (rowodd == root_odd) and bold_italic(disp) or disp
            out[#out+1] = string.format('| data-sort-value="%s" | %s', sort_key(r), shown)
        end
        out[#out+1] = "|}"
        out[#out+1] = "Enumerations in italics map to an existing octave-reduced rotation."
        return table.concat(out, "\n")
    end


     -- header
     -- header
Line 242: Line 170:
     end
     end


     -- Precompute one rotation map per column (voicing)
     -- precompute rotation maps with at most n rotations each
     local rotation_maps = {}
     local rotation_maps = {}
     for c, spec in ipairs(col_specs) do
     for c, spec in ipairs(col_specs) do
Line 248: Line 176:
         for _,od in ipairs(spec.set) do chosen[od] = true end
         for _,od in ipairs(spec.set) do chosen[od] = true end
         local base_voicing = apply_octaves(enum, odds, chosen)
         local base_voicing = apply_octaves(enum, odds, chosen)
         rotation_maps[c] = build_rotation_map(base_voicing)
         rotation_maps[c] = build_rotation_map_capped(base_voicing, want_odds_set, n)
     end
     end


     -- Emit rows
     -- rows
     for _,rowodd in ipairs(odd_rows) do
     for _,rowodd in ipairs(odd_rows) do
         out[#out+1] = "|-"
         out[#out+1] = "|-"
Line 259: Line 187:
             local rotated = rotation_maps[c][rowodd]
             local rotated = rotation_maps[c][rowodd]
             if not rotated then
             if not rotated then
                 -- fallback (rare): use reduced base voicing
                 -- If the capped cycle didn't hit this odd, fall back to reduced base voicing.
                 local chosen = {}
                 local chosen = {}
                 for _,od in ipairs(spec.set) do chosen[od] = true end
                 for _,od in ipairs(spec.set) do chosen[od] = true end
Line 266: Line 194:


             local disp = to_str(rotated)
             local disp = to_str(rotated)
            local sortv = sort_key(rotated)
             local shown
             local shown
             local is_two_octaves = (span_ratio(rotated) >= 4.0)
             local is_two_octaves = (span_ratio(rotated) >= 4.0)
Line 278: Line 205:
             end
             end


             out[#out+1] = string.format('| data-sort-value="%s" | %s', sortv, shown)
             out[#out+1] = string.format('| data-sort-value="%s" | %s', sort_key(rotated), shown)
         end
         end
     end
     end