Module:ChordVoicings: Difference between revisions

From Xenharmonic Wiki
Jump to navigation Jump to search
Eufalesio (talk | contribs)
Vibe coded v1.1
Eufalesio (talk | contribs)
No edit summary
 
(2 intermediate revisions by the same user not shown)
Line 1: Line 1:
-- Module:ChordVoicings (optimized to avoid CPU limit)
-- Module:ChordVoicings (ultra-lean, hard-capped)
-- Usage: {{ChordVoicingTable|rchord=4:5:6:7}}
-- Works fast on small chords (n ≤ 5). Rotations per column ≤ n. Columns ≤ 2^(n-1).


local p = {}
local p = {}


-- ---------- utilities ----------
-- ---------- tiny utils ----------
local function split_enum(s)
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 t = {}
local function gcd(a,b) while b~=0 do a,b=a%b,b end; return math.abs(a) end
    for num in string.gmatch(s or "", "%d+") do
local function reduce(t) local g=t[1] for i=2,#t do g=gcd(g,t[i]) end; if g>1 then local u={} for i=1,#t do u[i]=t[i]/g end; return u end; local u={} for i=1,#t do u[i]=t[i] end; return u end
        t[#t+1] = tonumber(num)
local function odd(n) while n%2==0 do n=math.floor(n/2) end; return n end
    end
local function odds_of(t) local o={} for i=1,#t do o[i]=odd(t[i]) end; return o end
    return t
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
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_ge_4(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) >= 4.0 end
local function italic(s) return "''"..s.."''" end
local function boldital(s) return "'''''"..s.."'''''" end
local function rot2_once(v) local n=#v; local u={} for i=2,n do u[#u+1]=v[i] end; u[#u+1]=v[1]*4; return reduce(u) end


local function gcd(a,b)
-- apply octave raises for chosen odds (set is {odd -> true})
    while b ~= 0 do a, b = a % b, b end
local function apply_octaves(enum, odds, chosen)
    return math.abs(a)
  local out={} for i=1,#enum do local x=enum[i]; if chosen[odds[i]] then x=x*2 end; out[i]=x end; return out
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 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
    local u = {}
    for i=1,#t do u[i]=t[i] end
    return u
end
end


local function odd_part(n)
-- Build rotation map with ≤ n steps, covering only wanted row odds
    while n % 2 == 0 do n = math.floor(n/2) end
local function rotation_map_capped(start_vec, want_set, n)
    return n
  local map = {}
end
  local v = reduce(start_vec)
local function toodds(enum)
  for step=1,n do
    local odds = {}
    local f = odd(v[1])
    for i=1,#enum do odds[i] = odd_part(enum[i]) end
     if want_set[f] and not map[f] then
     return odds
      map[f] = v
end
      -- early exit: have we covered all wanted odds?
 
      local ok=true
local function to_str(t)
      for w,_ in pairs(want_set) do if not map[w] then ok=false; break end end
    local parts = {}
      if ok then break end
    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
     end
     return mx/mn
     if step==n then break end
    v = rot2_once(v)
  end
  return map
end
end


-- two-octave rotation step
-- Deterministic, non-recursive subset listing for k ≤ 4
local function rot2_once(t)
local function subset_columns(nonroot_sorted, n_total)
    local n = #t
  -- total columns ≤ 2^(n-1). We’ll emit in size order: 0 (Root), 1-sets, 2-sets, 3-sets, 4-sets…
    local u = {}
  local cols = { {text="Root", set={}} }
    for i=2,n do u[#u+1] = t[i] end
  local cap = math.floor(2^(n_total-1)) - 1 -- non-root subsets cap
    u[#u+1] = t[1]*4
  local k = #nonroot_sorted
    return reduce_irreducible(u)
  if cap <= 0 or k == 0 then return cols end
end


local function unique_in_order(t)
  -- size-1
    local seen, out = {}, {}
  for i=1,math.min(k,cap>0 and k or 0) do
    for _,x in ipairs(t) do if not seen[x] then seen[x]=true; out[#out+1]=x end end
    cols[#cols+1] = { text="'"..nonroot_sorted[i], set={nonroot_sorted[i]} }
    return out
  end
end
  local used = math.min(k, cap)
  if used >= cap then return cols end


-- generate all non-empty subsets (small n only)
  -- size-2 (i<j)
local function all_nonempty_subsets(arr)
  for i=1,k-1 do
    local res = {}
    for j=i+1,k do
    local function rec(i, cur)
      cols[#cols+1] = { text="'"..nonroot_sorted[i].."'"..nonroot_sorted[j], set={nonroot_sorted[i], nonroot_sorted[j]} }
        if i > #arr then
      used = used + 1
            if #cur > 0 then
      if used >= cap then return cols end
                local add = {}
                for k=1,#cur do add[k]=cur[k] end
                res[#res+1] = add
            end
            return
        end
        rec(i+1, cur)
        cur[#cur+1] = arr[i]
        rec(i+1, cur)
        cur[#cur] = nil
     end
     end
    rec(1, {})
  end
    return res
end


local function subset_labels(nonroot_odds)
  -- size-3 (i<j<l)
    local nums = {}
  for i=1,k-2 do
     for i,x in ipairs(nonroot_odds) do nums[i]=x end
     for j=i+1,k-1 do
    table.sort(nums)
      for l=j+1,k do
 
        cols[#cols+1] = { text="'"..nonroot_sorted[i].."'"..nonroot_sorted[j].."'"..nonroot_sorted[l], set={nonroot_sorted[i], nonroot_sorted[j], nonroot_sorted[l]} }
    local labels = {}
        used = used + 1
    labels[#labels+1] = { text="Root", set={} }
        if used >= cap then return cols end
      end
    end
  end


    local subsets = all_nonempty_subsets(nums)
  -- size-4 (i<j<l<m) — practically never hit for n≤5, but included
     table.sort(subsets, function(a,b)
  for i=1,k-3 do
        if #a ~= #b then return #a < #b end
     for j=i+1,k-2 do
         for i=1,math.min(#a,#b) do
      for l=j+1,k-1 do
            if a[i] ~= b[i] then return a[i] < b[i] end
         for m=l+1,k do
          cols[#cols+1] = { text="'"..nonroot_sorted[i].."'"..nonroot_sorted[j].."'"..nonroot_sorted[l].."'"..nonroot_sorted[m], set={nonroot_sorted[i], nonroot_sorted[j], nonroot_sorted[l], nonroot_sorted[m]} }
          used = used + 1
          if used >= cap then return cols end
         end
         end
        return #a < #b
      end
    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
     end
    return labels
  end
end


local function apply_octaves(enum, odds, chosen_set)
  return cols
    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
end


local function italicize(s) return "''" .. s .. "''" end
function p.render(frame)
local function bold_italic(s) return "'''''" .. s .. "'''''" end
  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


-- ---------- key optimization ----------
   local enum = split_enum(rchord_str)
-- For a given voicing vector V, compute the entire rotation cycle once:
  local n = #enum
-- keep a map: firstOdd(V_k[1]) -> V_k   for every step k in the cycle.
  if n < 3 then return '<strong class="error">rchord must have at least 3 integers</strong>' end
local function build_rotation_map(start_vec)
    local map = {}
    local seen = {}
    local function sig(t) return sort_key(t) end -- signature for cycle detection
    local v = reduce_irreducible(start_vec)
    local first = sig(v)


    while true do
  local width = args.width or "120px"
        local fodd = odd_part(v[1])
  local title = args.title or "Voicings and rotations around two octaves"
        if not map[fodd] then map[fodd] = v end
  local cls  = args.class or "wikitable sortable"
        seen[sig(v)] = true
        local nextv = rot2_once(v)
        local id = sig(nextv)
        if seen[id] then break end
        v = nextv
    end
    return map
end


-- ---------- main render ----------
  local odds = odds_of(enum)
function p.render(frame)
  local root_odd = odd(enum[1])
    local args = frame.args or frame:getParent().args
  local odd_rows = unique_in_order(odds)
    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"
  -- rows we actually need
    local title = args.title or "Voicings and rotations around two octaves"
  local want = {}; for _,o in ipairs(odd_rows) do want[o]=true end
    local cls  = args.class or "wikitable sortable"


    local enum = split_enum(rchord_str)
  -- non-root odds, sorted
    if #enum < 3 then
  local nonroot = {}
        return "<strong class=\"error\">rchord must have at least 3 integers</strong>"
  for _,o in ipairs(odd_rows) do if o ~= root_odd then nonroot[#nonroot+1] = o end end
    end
  table.sort(nonroot)


    local odds = toodds(enum)
  -- columns (Root + capped subsets)
    local root_odd = odd_part(enum[1])
  local cols = subset_columns(nonroot, n)
    local odd_rows = unique_in_order(odds)


    local nonroot_odds_unique = {}
  -- header (no external template calls in header to keep it cheap)
    for _,o in ipairs(odd_rows) do
  local out = {}
        if o ~= root_odd then nonroot_odds_unique[#nonroot_odds_unique+1] = o end
  out[#out+1] = "== " .. title .. " =="
    end
  out[#out+1] = string.format('{| class="%s" style="text-align:center;"', cls)
    local col_specs = subset_labels(nonroot_odds_unique) -- {text,set}
  out[#out+1] = "|+"
  out[#out+1] = string.format('! style="width:%s;" | Rotation/Voicing', width)
  for _,c in ipairs(cols) do
    out[#out+1] = string.format('! style="width:%s;" | %s', width, c.text)
  end


    local out = {}
  -- precompute rotation maps for each column, with ≤ n rotations
    out[#out+1] = "== " .. title .. " =="
  local rotation_maps = {}
    out[#out+1] = string.format('{| class="%s" style="text-align:center;"', cls)
  for i,c in ipairs(cols) do
     out[#out+1] = "|+"
     local chosen = {}
    out[#out+1] = string.format('! style="width:%s;" {{diagonal split header|Rotation|Voicing}}', width)
     for _,odv in ipairs(c.set) do chosen[odv]=true end
     for _,spec in ipairs(col_specs) do
    local base = apply_octaves(enum, odds, chosen)
        out[#out+1] = string.format('! style="width:%s;" | %s', width, spec.text)
    rotation_maps[i] = rotation_map_capped(base, want, n)
    end
  end


    -- Precompute rotation maps for every column (voicing)
  -- rows
     local rotation_maps = {}
  for _,rowodd in ipairs(odd_rows) do
     for c, spec in ipairs(col_specs) do
    out[#out+1] = "|-"
     out[#out+1] = string.format('! style="width:%s;" | On %s', width, tostring(rowodd))
     for i,c in ipairs(cols) do
      local r = rotation_maps[i][rowodd]
      if not r then  -- capped cycle didn't hit this odd; show reduced base
         local chosen = {}
         local chosen = {}
         for _,od in ipairs(spec.set) do chosen[od] = true end
         for _,odv in ipairs(c.set) do chosen[odv]=true end
         local base_voicing = apply_octaves(enum, odds, chosen)
         r = reduce(apply_octaves(enum, odds, chosen))
        rotation_maps[c] = build_rotation_map(base_voicing)
      end
    end
      local s = to_str(r)
 
      local shown
    -- Emit rows: each cell becomes O(1) lookup
      if rowodd == root_odd and #c.set == 0 then
    for _,rowodd in ipairs(odd_rows) do
        shown = boldital(s)
        out[#out+1] = "|-"
      elseif not span_ge_4(r) then
        out[#out+1] = string.format('! style="width:%s;" | On %s', width, tostring(rowodd))
        shown = italic(s)
 
      else
        for c, spec in ipairs(col_specs) do
        shown = s
            local rotated = rotation_maps[c][rowodd]
      end
            -- In unusual cases where a given odd isn't in the cycle, fall back to the base voicing.
      out[#out+1] = string.format('| data-sort-value="%s" | %s', sort_key(r), shown)
            if not rotated then
                local fallback = {}
                for _,od in ipairs(spec.set) do end -- noop, just to keep structure similar
                local chosen = {}
                for _,od in ipairs(spec.set) do chosen[od] = true end
                rotated = apply_octaves(enum, odds, chosen)
                rotated = reduce_irreducible(rotated)
            end
 
            local disp = to_str(rotated)
            local sortv = sort_key(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', sortv, shown)
        end
     end
     end
  end


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


return p
return p

Latest revision as of 13:36, 11 November 2025

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

-- Module:ChordVoicings (ultra-lean, hard-capped)
-- Works fast on small chords (n ≤ 5). Rotations per column ≤ n. Columns ≤ 2^(n-1).

local p = {}

-- ---------- tiny 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 reduce(t) local g=t[1] for i=2,#t do g=gcd(g,t[i]) end; if g>1 then local u={} for i=1,#t do u[i]=t[i]/g end; return u end; local u={} for i=1,#t do u[i]=t[i] end; return u end
local function odd(n) while n%2==0 do n=math.floor(n/2) end; return n end
local function odds_of(t) local o={} for i=1,#t do o[i]=odd(t[i]) end; return o 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 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_ge_4(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) >= 4.0 end
local function italic(s) return "''"..s.."''" end
local function boldital(s) return "'''''"..s.."'''''" end
local function rot2_once(v) local n=#v; local u={} for i=2,n do u[#u+1]=v[i] end; u[#u+1]=v[1]*4; return reduce(u) end

-- apply octave raises for chosen odds (set is {odd -> true})
local function apply_octaves(enum, odds, chosen)
  local out={} for i=1,#enum do local x=enum[i]; if chosen[odds[i]] then x=x*2 end; out[i]=x end; return out
end

-- Build rotation map with ≤ n steps, covering only wanted row odds
local function rotation_map_capped(start_vec, want_set, n)
  local map = {}
  local v = reduce(start_vec)
  for step=1,n do
    local f = odd(v[1])
    if want_set[f] and not map[f] then
      map[f] = v
      -- early exit: have we covered all wanted odds?
      local ok=true
      for w,_ in pairs(want_set) do if not map[w] then ok=false; break end end
      if ok then break end
    end
    if step==n then break end
    v = rot2_once(v)
  end
  return map
end

-- Deterministic, non-recursive subset listing for k ≤ 4
local function subset_columns(nonroot_sorted, n_total)
  -- total columns ≤ 2^(n-1). We’ll emit in size order: 0 (Root), 1-sets, 2-sets, 3-sets, 4-sets…
  local cols = { {text="Root", set={}} }
  local cap = math.floor(2^(n_total-1)) - 1  -- non-root subsets cap
  local k = #nonroot_sorted
  if cap <= 0 or k == 0 then return cols end

  -- size-1
  for i=1,math.min(k,cap>0 and k or 0) do
    cols[#cols+1] = { text="'"..nonroot_sorted[i], set={nonroot_sorted[i]} }
  end
  local used = math.min(k, cap)
  if used >= cap then return cols end

  -- size-2 (i<j)
  for i=1,k-1 do
    for j=i+1,k do
      cols[#cols+1] = { text="'"..nonroot_sorted[i].."'"..nonroot_sorted[j], set={nonroot_sorted[i], nonroot_sorted[j]} }
      used = used + 1
      if used >= cap then return cols end
    end
  end

  -- size-3 (i<j<l)
  for i=1,k-2 do
    for j=i+1,k-1 do
      for l=j+1,k do
        cols[#cols+1] = { text="'"..nonroot_sorted[i].."'"..nonroot_sorted[j].."'"..nonroot_sorted[l], set={nonroot_sorted[i], nonroot_sorted[j], nonroot_sorted[l]} }
        used = used + 1
        if used >= cap then return cols end
      end
    end
  end

  -- size-4 (i<j<l<m) — practically never hit for n≤5, but included
  for i=1,k-3 do
    for j=i+1,k-2 do
      for l=j+1,k-1 do
        for m=l+1,k do
          cols[#cols+1] = { text="'"..nonroot_sorted[i].."'"..nonroot_sorted[j].."'"..nonroot_sorted[l].."'"..nonroot_sorted[m], set={nonroot_sorted[i], nonroot_sorted[j], nonroot_sorted[l], nonroot_sorted[m]} }
          used = used + 1
          if used >= cap then return cols end
        end
      end
    end
  end

  return cols
end

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 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

  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 odds = odds_of(enum)
  local root_odd = odd(enum[1])
  local odd_rows = unique_in_order(odds)

  -- rows we actually need
  local want = {}; for _,o in ipairs(odd_rows) do want[o]=true end

  -- non-root odds, sorted
  local nonroot = {}
  for _,o in ipairs(odd_rows) do if o ~= root_odd then nonroot[#nonroot+1] = o end end
  table.sort(nonroot)

  -- columns (Root + capped subsets)
  local cols = subset_columns(nonroot, n)

  -- header (no external template calls in header to keep it cheap)
  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;" | Rotation/Voicing', width)
  for _,c in ipairs(cols) do
    out[#out+1] = string.format('! style="width:%s;" | %s', width, c.text)
  end

  -- precompute rotation maps for each column, with ≤ n rotations
  local rotation_maps = {}
  for i,c in ipairs(cols) do
    local chosen = {}
    for _,odv in ipairs(c.set) do chosen[odv]=true end
    local base = apply_octaves(enum, odds, chosen)
    rotation_maps[i] = rotation_map_capped(base, want, 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 i,c in ipairs(cols) do
      local r = rotation_maps[i][rowodd]
      if not r then  -- capped cycle didn't hit this odd; show reduced base
        local chosen = {}
        for _,odv in ipairs(c.set) do chosen[odv]=true end
        r = reduce(apply_octaves(enum, odds, chosen))
      end
      local s = to_str(r)
      local shown
      if rowodd == root_odd and #c.set == 0 then
        shown = boldital(s)
      elseif not span_ge_4(r) then
        shown = italic(s)
      else
        shown = s
      end
      out[#out+1] = string.format('| data-sort-value="%s" | %s', sort_key(r), 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