Module:ChordVoicings: Difference between revisions
No edit summary |
No edit summary |
||
| Line 1: | Line 1: | ||
-- Module:ChordVoicings ( | -- 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 = {} | ||
-- ---------- | -- ---------- 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 | ||
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 reduce_irreducible(t) | local function reduce_irreducible(t) | ||
local g = gcd_list(t) | local g = gcd_list(t) | ||
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 | ||
return u | |||
u | |||
end | end | ||
return clone(t) | |||
return | |||
end | end | ||
-- two-octave rotation | -- 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 | ||
-- generate non-empty subsets (≤ 2^(k)-1) and cap explicitly | |||
local function all_nonempty_subsets_capped(arr, maxcount) | |||
local function | |||
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) | if #res < maxcount then rec(i+1, cur) end | ||
cur[#cur+1] = arr[i] | 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 = | 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 | ||
-- ---------- | -- ---------- rotation map with hard cap: ≤ n rotations ---------- | ||
local function | -- 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 v = reduce_irreducible(start_vec) | local v = reduce_irreducible(start_vec) | ||
for step = 1, n do | |||
local fodd = odd_part(v[1]) | local fodd = odd_part(v[1]) | ||
if not map[fodd] then map[fodd] = v | 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 | |||
if | end | ||
-- last iteration shouldn't rotate again | |||
v = | if step == n then break end | ||
v = rot2_once(v) | |||
end | end | ||
return map | return map | ||
end | end | ||
-- ---------- main | -- ---------- 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" | ||
local enum = split_enum(rchord_str) | 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>" | return "<strong class=\"error\">rchord must have at least 3 integers</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 | ||
local col_specs = subset_labels(nonroot_odds_unique, n) -- {text,set} | |||
local col_specs = subset_labels(nonroot_odds_unique) -- {text,set} | |||
-- header | -- header | ||
| Line 242: | Line 170: | ||
end | end | ||
-- | -- 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] = | rotation_maps[c] = build_rotation_map_capped(base_voicing, want_odds_set, n) | ||
end | end | ||
-- | -- 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 | ||
-- | -- 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 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', | out[#out+1] = string.format('| data-sort-value="%s" | %s', sort_key(rotated), shown) | ||
end | end | ||
end | end | ||