Module:ChordVoicings

Revision as of 13:27, 11 November 2025 by Eufalesio (talk | contribs)

Documentation for this module may be created at Module:ChordVoicings/doc

-- Module:ChordVoicings (tight caps: ≤ n rotations per column, ≤ 2^(n-1) voicings)
-- Usage: {{ChordVoicingTable|rchord=4:5:6:7}}

local p = {}

-- ---------- small utils ----------
local function split_enum(s)
    local t = {}
    for num in string.gmatch(s or "", "%d+") do t[#t+1] = tonumber(num) end
    return t
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 reduce_irreducible(t)
    local g = gcd_list(t)
    if g>1 then
        local u = {}
        for i=1,#t do u[i]=math.floor(t[i]/g) end
        return u
    end
    return clone(t)
end

-- one two-octave rotation
local function rot2_once(t)
    local n = #t
    local u = {}
    for i=2,n do u[#u+1] = t[i] end
    u[#u+1] = t[1]*4
    return reduce_irreducible(u)
end

-- generate non-empty subsets (≤ 2^(k)-1) and cap explicitly
local function all_nonempty_subsets_capped(arr, maxcount)
    local res = {}
    local function rec(i, cur)
        if #res >= maxcount then return end
        if i > #arr then
            if #cur > 0 then
                local add = {}
                for k=1,#cur do add[k]=cur[k] end
                res[#res+1] = add
            end
            return
        end
        if #res < maxcount then rec(i+1, cur) end
        if #res < maxcount then
            cur[#cur+1] = arr[i]
            rec(i+1, cur)
            cur[#cur] = nil
        end
    end
    rec(1, {})
    return res
end

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 = {}
    for i,x in ipairs(nonroot_odds) do nums[i]=x end
    table.sort(nums)

    local labels = {}
    labels[#labels+1] = { text="Root", set={} }

    local subsets = all_nonempty_subsets_capped(nums, max_subsets)
    table.sort(subsets, function(a,b)
        if #a ~= #b then return #a < #b end
        for i=1,math.min(#a,#b) do
            if a[i] ~= b[i] then return a[i] < b[i] end
        end
        return #a < #b
    end)
    for _,set in ipairs(subsets) do
        local parts={}
        for i,od in ipairs(set) do parts[i] = "'" .. tostring(od) end
        labels[#labels+1] = { text=table.concat(parts, ""), set=set }
    end
    return labels
end

local function apply_octaves(enum, odds, chosen_set)
    local out = {}
    for i=1,#enum do
        local v = enum[i]
        if chosen_set[odds[i]] then v = v * 2 end
        out[i] = v
    end
    return out
end

local function italicize(s) return "''" .. s .. "''" end
local function bold_italic(s) return "'''''" .. s .. "'''''" end

-- ---------- rotation map with hard cap: ≤ n rotations ----------
-- 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 v = reduce_irreducible(start_vec)
    for step = 1, n do
        local fodd = odd_part(v[1])
        if not map[fodd] then
            map[fodd] = v
            -- early exit if all wanted odds are covered
            local all_ok = true
            for od,_ in pairs(want_odds_set) do
                if not map[od] then all_ok=false; break end
            end
            if all_ok then break end
        end
        -- last iteration shouldn't rotate again
        if step == n then break end
        v = rot2_once(v)
    end
    return map
end

-- ---------- main ----------
function p.render(frame)
    local args = frame.args or frame:getParent().args
    local rchord_str = (args.rchord or args[1] or ""):gsub("%s+", "")
    if rchord_str == "" then
        return "<strong class=\"error\">Missing parameter: rchord</strong>"
    end

    local width = args.width or "120px"
    local title = args.title or "Voicings and rotations around two octaves"
    local cls   = args.class or "wikitable sortable"

    local enum = split_enum(rchord_str)
    local n = #enum
    if n < 3 then
        return "<strong class=\"error\">rchord must have at least 3 integers</strong>"
    end

    -- odds & rows
    local odds = toodds(enum)
    local root_odd = odd_part(enum[1])
    local odd_rows = unique_in_order(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 = {}
    for _,o in ipairs(odd_rows) do if o ~= root_odd then nonroot_odds_unique[#nonroot_odds_unique+1] = o end end
    local col_specs = subset_labels(nonroot_odds_unique, n) -- {text,set}

    -- header
    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)
    for _,spec in ipairs(col_specs) do
        out[#out+1] = string.format('! style="width:%s;" | %s', width, spec.text)
    end

    -- precompute rotation maps with at most n rotations each
    local rotation_maps = {}
    for c, spec in ipairs(col_specs) do
        local chosen = {}
        for _,od in ipairs(spec.set) do chosen[od] = true end
        local base_voicing = apply_octaves(enum, odds, chosen)
        rotation_maps[c] = build_rotation_map_capped(base_voicing, want_odds_set, n)
    end

    -- rows
    for _,rowodd in ipairs(odd_rows) do
        out[#out+1] = "|-"
        out[#out+1] = string.format('! style="width:%s;" | On %s', width, tostring(rowodd))

        for c, spec in ipairs(col_specs) do
            local rotated = rotation_maps[c][rowodd]
            if not rotated then
                -- If the capped cycle didn't hit this odd, fall back to reduced base voicing.
                local chosen = {}
                for _,od in ipairs(spec.set) do chosen[od] = true end
                rotated = reduce_irreducible(apply_octaves(enum, odds, chosen))
            end

            local disp = to_str(rotated)
            local shown
            local is_two_octaves = (span_ratio(rotated) >= 4.0)

            if rowodd == root_odd and #spec.set == 0 then
                shown = bold_italic(disp)
            elseif not is_two_octaves then
                shown = italicize(disp)
            else
                shown = disp
            end

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

    out[#out+1] = "|}"
    out[#out+1] = "Enumerations in italics map to an existing octave-reduced rotation."
    return table.concat(out, "\n")
end

return p