Module:JI ratios

From Xenharmonic Wiki
Jump to navigation Jump to search
Module documentation[view] [edit] [history] [purge]
Todo: add documentation

local p = {}

local getArgs = require("Module:Arguments").getArgs
local med = require("Module:Mediants")
local rat = require("Module:Rational")
local tip = require("Module:Template input parse")
local utils = require("Module:Utils")
local yesno = require("Module:Yesno")

-- TODO: write filter function for cent range

-- Template for handling multiple entry of JI ratios into a template, and for
-- searching for JI ratios if automatic entry is desired.
-- This is a successor/replacement for JI ratio finder.

-- Module searches for ratios that are, at the minimum, up to an equave and are
-- up to some integer limit. Search hierarchy is as follows:
-- - Search by subgroup (includes non-integer and rational elements)
-- - Then search by prime limit
-- - Then search by odd limit (to be implemented)
-- - Then search by int limit

-- Optional args omit ratios that don't meet certain conditions, and are used
-- to further limit the number of ratios found. Current options include:
-- - Tenney Height: omits ratios that exceed some max Tenney height. Has no
--   effect if no Tenney height is passed in.
-- - Complements Only: omits ratios and their equave complements if either would
--   be omitted by Tenney height, or if no Tenney height is entered, omits
--   ratios whose complements are missing.

local DEFAULT_EQUAVE = rat.new(2)
local DEFAULT_INT_LIMIT = 30

--------------------------------------------------------------------------------
------------------------------- FILTER FUNCTIONS -------------------------------
--------------------------------------------------------------------------------

-- Filter function removes certain ratios that don't meet some requirement.
-- Filters currently include:
-- - Removing ratios that exceed a max Tenney height.
-- - Removing ratios whose complement would exceed a max Tenney height or int limit
function p.filter_ratios(ratios, equave, int_limit, tenney_height, complements_only)
	
	local filtered_ratios = {}
	for i = 1, #ratios do
		local complement = rat.mul(rat.inv(ratios[i]), equave)
		local ratio_th   = rat.tenney_height(ratios[i])
		local compl_th   = rat.tenney_height(complement)
		
		-- Are the ratios within the Tenney height?
		-- Has no effect (defaults to TRUE) if Tenney height is infinity.
		local ratio_within_th = ratio_th <= tenney_height
		local compl_within_th = compl_th <= tenney_height
		
		-- Is the ratio's complement within the int limit?
		local compl_within_int_limit = rat.is_within_int_limit(complement, int_limit)
		
		if complements_only then
			if ratio_within_th and compl_within_th and compl_within_int_limit then
				table.insert(filtered_ratios, ratios[i])
			end
		else
			if ratio_within_th then
				table.insert(filtered_ratios, ratios[i])
			end
		end
	end
	
	return filtered_ratios
end

-- Filters ratios from a table of ratios, returning an array of ratios within
-- the cent range and preserving the original table. Meant for searching for
-- multiple ranges. TODO: write
function p.filter_ratios_within_cent_range(ratios, min_cents, max_cents)
	
end

--------------------------------------------------------------------------------
-------------------------- INT-LIMIT SEARCH FUNCTION ---------------------------
--------------------------------------------------------------------------------

-- Int limit search finds ratios from 1/1 to an equave, where each ratio's
-- numerator or denominator don't exceed the int limit.
function p.search_by_int_limit(equave, int_limit)
	return p.search_by_int_limit_within_cents(0, rat.cents(equave), int_limit)
end

-- Cent range search finds ratios within a cent range. Meant for searching for
-- ratios within a single interval range. If searching for ratios within many
-- interval ranges, then try a broad search first.
function p.search_by_int_limit_within_cents(min_cents, max_cents, int_limit)
	
	local init_ratios = {{1,1}, {1,0}}
	local ratios = med.find_only_mediants(init_ratios, 2)
	for i = 3, int_limit do
		ratios = med.find_mediants_by_int_limit(ratios, i)
		
		-- Purge ratios from the beginning.
		-- If the first and second ratio are smaller than min_cents, and smaller
		-- than max_cents, then remove the first ratio. Keeping the first ratio
		-- would add mediants outside the cent range.
		local cents_1 = utils.log2(ratios[1][1] / ratios[1][2]) * 1200
		local cents_2 = utils.log2(ratios[2][1] / ratios[2][2]) * 1200
		if cents_1 < min_cents and cents_2 <= min_cents and cents_1 < max_cents and cents_2 < max_cents then
			table.remove(ratios, 1)
		end
		
		-- Purge ratios from the end.
		-- If the 2nd-last ratio and last ratio are greater than max_cents, and
		-- larger than min_cents, then remove the last ratio. Keeping the last
		-- ratio would add mediants outside the cent range.
		local cents_3 = utils.log2(ratios[#ratios-1][1] / ratios[#ratios-1][2]) * 1200
		local cents_4 = utils.log2(ratios[#ratios  ][1] / ratios[#ratios  ][2]) * 1200
		if cents_3 > max_cents and cents_4 >= max_cents and cents_3 > min_cents and cents_4 > min_cents then
			table.remove(ratios, #ratios)
		end
	end
	
	-- Convert to ratios that Module:Rational can work with
	for i = 1, #ratios do
		ratios[i] = rat.new(ratios[i][1], ratios[i][2])
	end
	
	-- Remove any remaining ratios that fall outside the cent range.
	while rat.cents(ratios[1]) < min_cents do
		table.remove(ratios, 1)
	end
	while rat.cents(ratios[#ratios]) > max_cents do
		table.remove(ratios, #ratios)
	end
	
	return ratios
end

--------------------------------------------------------------------------------
-------------------------- ODD-LIMIT SEARCH FUNCTION ---------------------------
--------------------------------------------------------------------------------

-- to be implemented

--------------------------------------------------------------------------------
------------------------- PRIME-LIMIT SEARCH FUNCTION --------------------------
--------------------------------------------------------------------------------

function p.prime_limit_to_subgroup(prime_limit)
	local subgroup = {}
	for i = 2, prime_limit do
		local is_prime = true
		for j = 2, math.floor(math.sqrt(i)) do
			if i % j == 0 then
				is_prime = false
				break
			end
		end
		if is_prime then
			table.insert(subgroup, rat.new(i))
		end
	end
	return subgroup
end

-- Prime limit search finds ratios with prime factors that don't exceed some
-- prime limit.
-- Upper bounds for searching is the equave and int limit.
function p.search_by_prime_limit(equave, int_limit, prime_limit)
	local subgroup = p.prime_limit_to_subgroup(prime_limit)
	return p.search_by_subgroup_within_cents(0, rat.cents(equave), int_limit, subgroup)
end

-- Prime limit search finds ratios with prime factors that don't exceed some
-- prime limit. Searches within a cent range.
function p.search_by_prime_limit_within_cents(min_cents, max_cents, int_limit, prime_limit)
	local subgroup = p.prime_limit_to_subgroup(prime_limit)
	local ratios = p.search_by_subgroup_within_cents(min_cents, max_cents, int_limit, subgroup)
	while rat.cents(ratios[1]) < min_cents do
		table.remove(ratios, 1)
	end
	return ratios
end

--------------------------------------------------------------------------------
---------------------------- SUBGROUP SEARCH FUNCTION --------------------------
--------------------------------------------------------------------------------

-- Subgroup search find ratios that are products of at least two non-unique
-- elements from the subgroup.
function p.search_by_subgroup(equave, int_limit, subgroup)
	local ratios = p.search_by_subgroup_within_cents(0, rat.cents(equave), int_limit, subgroup)
	return ratios
end

function p.search_by_subgroup_within_cents(min_cents, max_cents, int_limit, subgroup)
	--local equave    = equave or rat.new(2,1)	-- Defualt equave is 2/1.
	--local int_limit = int_limit or 50			-- Default is 50
	--local subgroup  = subgroup or {rat.new(2), rat.new(3), rat.new(7)}		-- Default is 2.3.7 subgroup
	
	-- Find all possible ways to multiply subgroup elements with one another
	-- using breadth-first-search. Products found this way should not exceed the
	-- int limit, and if a subgroup element is rational, neither its numerator
	-- nor denominator should exceed the int limit.
	local products = { rat.new(1) }
	local i = 1
	while i <= #products do
		-- Multiply each subgroup element by the current ratio. The table of
		-- product ratios created this way is merged with the running table of
		-- ratios. This is the Cartesian product of the single ratio as a set,
		-- with the subgroup elements as a set, or {p/q} X subgroup.
		local new_products = {}
		for j = 1, #subgroup do
			local new_ratio = rat.mul(products[i], subgroup[j])
			if rat.is_within_int_limit(new_ratio, int_limit) and not p.find_ratio_in_table(new_products, new_ratio) then
				table.insert(new_products, new_ratio)
			end
		end
		
		-- Merge new products with the table of products, omitting duplicates.
		p.merge_tables(products, new_products)
		i = i + 1
	end
	
	-- Sort for next step
	table.sort(products, rat.lt)
	
	-- Use the products found to find all ratios between 1 and the equave.
	-- For each ratio in the table of products, create a set of new ratios by
	-- having that ratio be the numerator and all successive ratios be possible
	-- denominators. Store these new ratios in a table, and repeat with all
	-- successive products, omitting duplicats. From earlier testing, this is
	-- faster than performing BFS on each ratio, and yields the same results.
	local ratios = {}
	for i = 1, #products do
		local new_ratios = {}
		for j = i, #products do
			local new_ratio = rat.div(products[j], products[i])	
			if rat.cents(new_ratio) > max_cents then break end
			
			if not p.find_ratio_in_table(new_ratios, new_ratio) and rat.is_within_int_limit(new_ratio, int_limit) then
				table.insert(new_ratios, new_ratio)
			end
		end
		
		-- Merge new ratios with the table of ratios, omitting duplicates.
		p.merge_tables(ratios, new_ratios)
	end
	
	-- Sort
	table.sort(ratios, rat.lt)
	
	-- Remove ratios less than minimum
	while rat.cents(ratios[1]) < min_cents do
		table.remove(ratios, 1)
	end
	
	return ratios
end

-- Heleper function; merges elements from source table with destination table
-- while disallowing duplicates.
function p.merge_tables(dest_table, source_table)
	for i = 1, #source_table do
		if not p.find_ratio_in_table(dest_table, source_table[i]) then
			table.insert(dest_table, source_table[i])
		end
	end
end

-- Helper function for merge function.
function p.find_ratio_in_table(table_, ratio)
	local found = false
	for i = 1, #table_ do
		if rat.as_float(table_[i]) == rat.as_float(ratio) then
			found = true
			break
		end
	end
	return found
end

--------------------------------------------------------------------------------
---------------------------- RATIO STRING FUNCTIONS ----------------------------
--------------------------------------------------------------------------------

-- Convert a table of ratios into a string, with options for links and delimiter
function p.ratios_as_string(ratios, add_links, delimiter)
	local add_links = add_links == true
	local delimiter = delimiter or ", "
	
	local text = ""
	if #ratios ~= 0 then
		text = add_links and string.format("[[%s]]", rat.as_ratio(ratios[1])) or rat.as_ratio(ratios[1])
		for i = 2, #ratios do
			text = text .. (add_links and string.format("%s[[%s]]", delimiter, rat.as_ratio(ratios[i])) or string.format("%s%s", delimiter, rat.as_ratio(ratios[i])))
		end
	end
	return text
end

-- Convert a jagged array of ratios into an array of strings
function p.ratios_as_strings(ratios, add_links, delimiter)
	local add_links = add_links == true
	local delimiter = delimiter or ", "
	
	local texts = {}
	for i = 1, #ratios do
		local text = p.ratios_as_string(ratios[i], add_links, delimiter)
		table.insert(texts, text)
	end
	return texts
end

--------------------------------------------------------------------------------
---------------------------- ARG-PARSING FUNCTION ------------------------------
--------------------------------------------------------------------------------

-- Parse search args if entered as one string. Use is to be determined.
function p.parse_args(search_args)
	local parsed = tip.parse_kv_pairs(search_args)
	
	if parsed["Equave"] ~= nil then
		parsed["Equave"] = rat.parse(parsed["Equave"])
	end
	
	if parsed["Int Limit"] ~= nil then
		parsed["Int Limit"] = tonumber(parsed["Int Limit"])
	end
	
	if parsed["Tenney Height"] ~= nil then
		parsed["Tenney Height"] = tonumber(parsed["Tenney Height"])
	end
	
	if parsed["Prime Limit"] ~= nil then
		parsed["Prime Limit"] = tonumber(parsed["Prime Limit"])
	end
	
	if parsed["Subgroup"] ~= nil then
		local subgroup_elements = tip.parse_numeric_pairs(parsed["Subgroup"], ".", "/", true)
		for i = 1, #subgroup_elements do
			subgroup_elements[i] = rat.new(subgroup_elements[i][1], subgroup_elements[i][2])
		end
		parsed["Subgroup"] = subgroup_elements
	end
	
	if parsed["Complements Only"] ~= nil then
		parsed["Complements Only"] = yesno(parsed["Complements Only"])
	end
	
	return parsed
end

--------------------------------------------------------------------------------
----------------------------- INVOKABLE FUNCTIONS ------------------------------
--------------------------------------------------------------------------------

-- Function callable by other modules
-- Ratios are returned as a table, for use with other modules.
function p._ji_ratios(args)
	-- Args for ease of access
	equave      = args["Equave"]		or DEFAULT_EQUAVE
	int_limit   = args["Int Limit"]		or DEFAULT_INT_LIMIT
	odd_limit   = args["Odd Limit"]
	prime_limit = args["Prime Limit"]
	subgroup    = args["Subgroup"]
	
	-- Filtering args
	tenney_height    = args["Tenney Height"]    or 1/0		-- Default Tenney height is infinity
	complements_only = args["Complements Only"] or false	-- Default is to include all ratios
	
	local ratios = {}
	if subgroup ~= nil then
		ratios = p.search_by_subgroup(equave, int_limit, subgroup)
	elseif prime_limit ~= nil then
		ratios = p.search_by_prime_limit(equave, int_limit, prime_limit)
	elseif int_limit ~= nil then
		ratios = p.search_by_int_limit(equave, int_limit)
	end
	
	-- Filter ratios
	ratios = p.filter_ratios(ratios, equave, int_limit, tenney_height, complements_only)
	
	return ratios
end

-- Invokable function; for templates
-- Ratios are returned as a comma-delimited list, for use with being displayed
-- as a list.
function p.ji_ratios(frame)
	args = getArgs(frame)
	
	-- Preprocess equave
	-- Ratios are searched from 1/1 to some equave (default 2/1), so an equave
	-- must be passed in.
	args["Equave"] = args["Equave"] ~= nil and rat.parse(args["Equave"])
	
	-- Preprocess int limit
	-- Ratios are searched up to some int limit (default 50), so an int limit
	-- must be passed in.
	args["Int Limit"] = args["Int Limit"] ~= nil and tonumber(args["Int Limit"])

	-- Preprocess Tenney height
	if args["Tenney Height"] ~= nil then
		args["Tenney Height"] = tonumber(args["Tenney Height"])
	end
	
	-- Preprocess prime limit
	if args["Prime Limit"] ~= nil then
		args["Prime Limit"] = tonumber(args["Prime Limit"])
	end
	
	-- Preprocess subgroup
	if args["Subgroup"] ~= nil then
		local subgroup_elements = tip.parse_numeric_pairs(args["Subgroup"], ".", "/", true)
		for i = 1, #subgroup_elements do
			subgroup_elements[i] = rat.new(subgroup_elements[i][1], subgroup_elements[i][2])
		end
		args["Subgroup"] = subgroup_elements
	end
	
	if args["Complements Only"] ~= nil then
		args["Complements Only"] = yesno(args["Complements Only"], false)
	end
	
	-- Find and return ratios
	local result = p.ratios_as_string(p._ji_ratios(args))
	local debugg = yesno(frame.args["debug"])
	
	if debugg == true then
		result = "<syntaxhighlight lang=\"wikitext\">" .. result .. "</syntaxhighlight>"
	end
	
	return frame:preprocess(result)

end

function p.tester()
	--return p.ratios_as_string(p._ji_ratios(p.parse_args("Int Limit: 16; Equave: 3/1; Complements Only: 0")))
	--return p.ratios_as_string(p.search_by_prime_limit_within_cents(372, 440, 17, 30))
	return p.ratios_as_string(p.search_by_int_limit_within_cents(380, 420, 50))
end

--------------------------------------------------------------------------------
---------------------------- FUNCTIONS TO BE MOVED -----------------------------
--------------------------------------------------------------------------------

-- Parse a list of ratios from a string. String is formatted as follows:
-- "a/b; c/d; e/f; g/h"
function p.parse_ratios(unparsed)
	local parsed = tip.parse_numeric_pairs(unparsed)
	for i = 1, #parsed do
		parsed[i] = rat.new(parsed[i][1], parsed[i][2])
	end
	return parsed
end

-- Sorts ratios by closeness to cent values. Move to new module?
function p.sort_by_closeness_to_cent_values(ratios, cent_values, tolerance)
	local tolerance = tolerance or 30
	
	local sorted_ratios = {}
	local curr_index = 1		-- Index of current_ratio
	for i = 1, #cent_values do
		local lower_bound = cent_values[i] - tolerance
		local upper_bound = cent_values[i] + tolerance
		local cents_within_range = true
		local curr_ratios = {}
		
		for j = curr_index, #ratios do
			local curr_ratio = ratios[j]
			local curr_cents = rat.cents(curr_ratio)
			
			if lower_bound < curr_cents and curr_cents < upper_bound then
				table.insert(curr_ratios, curr_ratio)
			--elseif curr_cents > upper_bound then
			--	curr_index = j
			--	break
			end
		end
		
		table.insert(sorted_ratios, curr_ratios)
	end
	
	return sorted_ratios
end

return p