Module:MOS tunings

From Xenharmonic Wiki
Jump to navigation Jump to search

Documentation transcluded from /doc
Note: Do not invoke this module directly; use the corresponding template instead: Template:MOS tunings.
Icon-Todo.png Todo: Documentation

local mos = require("Module:MOS")
local tamnams = require("Module:TAMNAMS")
local et = require("Module:ET")
local yesno = require("Module:Yesno")
local tip = require("Module:Template input parse")
local jira = require("Module:JI ratios")
local p = {}

-- Rewritten/simplified module replacement for Module:MOS degrees
-- A new template is chosen because it's a better name than the old one and is
-- far easier to maintain than the old one.

-- TODO:
-- - Relegate JI ratio input to sorting entered ratios; this is to decouple
--   ratio search from ratio sorting/filtering.
-- - Diatonic interval category lookup?

-- Helper function
-- Capitalizes the first character of a string
function p.capitalize_first(text)
	return string.upper(string.sub(text, 1, 1)) .. string.sub(text, 2, -1)
end

-- Helper function
-- Sorts step ratios L:s by their hardnesses
function p.sort_step_ratios(step_ratios)
	if #step_ratios < 2 then
		return step_ratios
	end
	
	-- Sort using selection sort, which is ok for smol datasets.
	for i = 1, #step_ratios - 1 do
		local index_of_smallest = i
		local current_val = step_ratios[i][1] / step_ratios[i][2]
		
		-- Find the ratio with the smallest hardness
		for j = i + 1, #step_ratios do
			if (step_ratios[j][1] / step_ratios[j][2] < current_val) then
				index_of_smallest = j
			end
		end
		
		if index_of_smallest ~= i then
			local temp = step_ratios[index_of_smallest]
			step_ratios[index_of_smallest] = step_ratios[i]
			step_ratios[i] = temp
		end
	end
	return step_ratios
end

-- Helper function
-- Finds the step ratio range and sorts step ratios
function p.preprocess_step_ratios(step_ratios)
	local step_ratios = p.sort_step_ratios(step_ratios)
	local step_ratio_range = ""
	
	-- If the step ratios are 3/2, 2/1, and 3/1 in that order, then they are
	-- the simple step ratios: basic, hard, and soft.
	-- These should not be sorted, since the basic-hard-soft sorting is a little
	-- more intuitive than sorting by hardness.
	if #step_ratios == 3 then
		if step_ratios[1][1] == 3 and step_ratios[1][2] == 2
			and step_ratios[2][1] == 2 and step_ratios[2][2] == 1
			and step_ratios[3][1] == 3 and step_ratios[3][2] == 1 then
			return "Simple Tunings", {{2, 1}, {3, 1}, {3, 2}}
		end
	end
	
	-- If there are multiple step ratios, find the step ratio range it
	-- corresponds to. If there is one step ratio, find the name of that
	-- hardness. If there are zero step ratios, then return "Tunings"
	if #step_ratios > 1 then
		local lower_ratio = step_ratios[1]
		local upper_ratio = step_ratios[#step_ratios]
		
		step_ratio_range = tamnams.find_step_ratio_range_for_ratio_pair(lower_ratio, upper_ratio)
		if step_ratio_range ~= nil then
			step_ratio_range = p.capitalize_first(step_ratio_range) .. " Tunings"
		else
			step_ratio_range = "Tunings"
		end
	elseif #step_ratios == 1 then
		step_ratio_range = tamnams.lookup_step_ratio(step_ratios[1])
		if step_ratio_range ~= nil then
			step_ratio_range = p.capitalize_first(step_ratio_range) .. " Tuning"
		else
			step_ratio_range = string.format("%s/%s", step_ratios[1][1], step_ratios[1][2]) .. " Tuning"
		end
	else
		step_ratio_range = "Tunings"
	end
	
	return step_ratio_range, step_ratios
end

-- Preprocess step ratios
function p.preprocess_ji_ratios(input_mos, modal_union, step_ratios, ji_ratios, tolerance)

	-- Calculate the avegrage step ratio
	local avg_step_ratio = {0, 0}
	for i = 1, #step_ratios do
		avg_step_ratio[1] = avg_step_ratio[1] + step_ratios[i][1]
		avg_step_ratio[2] = avg_step_ratio[2] + step_ratios[i][2]
	end
	avg_step_ratio[1] = avg_step_ratio[1] / #step_ratios
	avg_step_ratio[2] = avg_step_ratio[2] / #step_ratios

	-- Normalize step ratio to be x:1, accounting for 1:0
	if avg_step_ratio[2] ~= 0 then
		avg_step_ratio[1] = avg_step_ratio[1] / avg_step_ratio[2]
		avg_step_ratio[2] = avg_step_ratio[2] / avg_step_ratio[2]
	else
		avg_step_ratio[1] = 1
		avg_step_ratio[2] = 0
	end
	
	-- Calculate the tolerance, the range in which ratios can be accepted +/-
	-- from an et-step. (ET may be a non-integer value, since the L:s ratio is
	-- normalized to x:1.)
	-- Tolerance is how many cents away from an et-step a ratio can be. This is
	-- by default 30% of the small step size, and maxes out at 30 cent. Can be
	-- overridden with a custom tolerance value.
	local steps_in_et = input_mos.nL * avg_step_ratio[1] + input_mos.ns * avg_step_ratio[2]
	local tolerance = tolerance or math.min((mos.equave_to_cents(input_mos) / steps_in_et) * 0.30, 30)
	
	-- Calculate the cent values for each interval in the modal union
	local cent_values = {}
	for i = 1, #modal_union do
		table.insert(cent_values, mos.interval_to_cents(modal_union[i], input_mos, avg_step_ratio))
	end
	
	local sorted_ratios = jira.sort_by_closeness_to_cent_values(ji_ratios, cent_values, tolerance)
	
	return sorted_ratios
end

-- Main function
function p._mos_tunings(input_mos, mos_prefix, mos_abbrev, step_ratios, ji_ratios, tolerance, footnotes, is_collapsed)
	local input_mos = input_mos or mos.new(5,2)
	local mos_prefix = mos_prefix or "mos"
	local mos_abbrev = mos_abbrev or "m"
	local step_ratios = step_ratios or {{2, 1}, {3, 1}, {3, 2}}
	local ji_ratios = ji_ratios or {}
	local tolerance = tolerance or nil
	local is_collapsed = is_collapsed == true
	local footnotes = footnotes or "(footnotes here)"
	
	-- Scalesig
	local scale_sig = mos.as_string(input_mos)
	
	-- Sort/preprocess step ratios
	local step_ratio_range = ""
	step_ratio_range, step_ratios = p.preprocess_step_ratios(step_ratios)
	
	-- Preprocess JI ratios
	local modal_union = mos.modal_union(input_mos)
	--local sorted_ji_ratios, search_info = p.preprocess_ji_ratios(input_mos, modal_union, step_ratios, ji_ratios, tolerance)
	
	-- Create table
	local result = "{| class=\"wikitable sortable right-all left-1 left-2 mw-collapsible" .. (is_collapsed and " mw-collapsed\"\n" or "\"\n")
	
	-- Table caption
	result = result	.. "|+ style=\"font-size: 105%; white-space: nowrap;\" | " .. string.format("%s of %s\n", step_ratio_range, scale_sig)
	
	-- First row of headers
	-- First two headers span two rows
	result = result
		.. "! rowspan=\"2\" class=\"unsortable\" | Scale degree\n"
		.. "! rowspan=\"2\" class=\"unsortable\" | Abbrev.\n"
	
	-- Headers for tunings; these span two cols
	for i = 1, #step_ratios do
		local step_ratio_as_text = tamnams.lookup_step_ratio(step_ratios[i])
		if step_ratio_as_text == nil then
			step_ratio_as_text = string.format("%s:%s", step_ratios[i][1], step_ratios[i][2])
		else
			step_ratio_as_text = p.capitalize_first(step_ratio_as_text) .. string.format(" (%s:%s)", step_ratios[i][1], step_ratios[i][2])
		end
		
		local et_as_string = et.as_string(mos.mos_to_et(input_mos, step_ratios[i]))
		local header_text = string.format("%s<br />[[%s]]", step_ratio_as_text, et_as_string)
		result = result .. string.format("! colspan=\"2\" | %s\n", header_text)
	end
	
	-- Headers for JI ratios; this spans two rows
	-- Commented out, pending rewrite
	--[[
	if #ji_ratios ~= 0 then
		result = result .. "! rowspan=\"2\" class=\"unsortable\" | Approx. ratios*\n"
	end
	]]--
	result = result .. "|-\n"
	
	-- Second row of headers
	for i = 1, #step_ratios do
		result = result .. "! style=\"border-right: none;\" class=\"unsortable\" | Steps\n"
		result = result .. "! style=\"border-left: none; text-align: right;\" | ¢\n"
	end
	
	-- Add a row for each scale degree
	for i = 1, #modal_union do
		local interval = modal_union[i]
		
		-- Add cells for the degree names
		local degree_name   = tamnams.degree_quality(interval, input_mos, "sentence-case", mos_prefix)
		local degree_abbrev = tamnams.degree_quality(interval, input_mos, "abbrev"       , mos_abbrev)
		
		result = result
			.. "|-\n"
			.. string.format("| %s || %s", degree_name, degree_abbrev)
	
		-- Add cells for each interval's tunings
		for j = 1, #step_ratios do
			local step_ratio = step_ratios[j]
			local step_count = mos.interval_to_et_steps(interval, step_ratio)
			local cents = mos.interval_to_cents(interval, input_mos, step_ratio)
			
			result = result
				--.. string.format(" || %s\\%s || %.1f", step_count, input_mos.nL * step_ratio[1] + input_mos.ns * step_ratio[2], cents)
				.. string.format(" || style=\"border-right: none;\" | %s\\%s || style=\"border-left: none;\" | %.1f", step_count, input_mos.nL * step_ratio[1] + input_mos.ns * step_ratio[2], cents)
		end
		
		-- Add cells for JI ratios
		--[[
		-- Commented out, pending rewrite
		if #ji_ratios ~= 0 then
			-- Ratios link to their respective pages, and are comma-delimited.
			local ratios_as_text = jira.ratios_as_string(sorted_ji_ratios[i], true, ",&nbsp;")
			result = result .. " || style=\"text-align: left;\" | " .. string.format("%s", ratios_as_text)
		end
		]]--
		
		result = result .. "\n"
	end
	
	-- End of table, plus footnotes
	result = result .. "|}\n"

	--[[
	-- Commented out, pending rewrite
	if #ji_ratios ~= 0 then
		-- Make footnote text smaller than the rest of the text to avoid confusion with paragraph text
		result = result .. string.format("<span style=\"font-size: 0.75em;\">&#42; %s</span>", footnotes)
	end
	]]--
	
	return result
end

-- Parse step ratios passed into template
-- If the unparsed string is blank, default to the simple tunings.
-- If the unparsed string is any of the step ratio range names, list the named
-- ratios that fall within that range.
-- if the unparsed string is blank, don't show any ratios.
-- If the ratios is a list, parse it.
function p.parse_step_ratios(unparsed)
	local parsed = {}
	
	local lookup_table = {
		["Central Spectrum"] = {{4, 3}, {3, 2}, {5, 3}, {2, 1}, {5, 2}, {3, 1}, {4, 1}},
		["Simple Tunings"]   = {{2, 1}, {3, 1}, {3, 2}},
		["Soft-of-basic"]    = {{4, 3}, {3, 2}, {2, 1}},
		["Ultrasoft"]        = {{6, 5}, {5, 4}, {4, 3}},
		["Parasoft"]		 = {{4, 3}, {7, 5}, {3, 2}},
		["Quasisoft"]        = {{3, 2}, {8, 5}, {5, 3}},
		["Minisoft"]         = {{5, 3}, {7, 4}, {2, 1}},
		["Hyposoft"]         = {{3, 2}, {5, 3}, {2, 1}},
		["Hypohard"]         = {{2, 1}, {5, 2}, {3, 1}},
		["Minihard"]         = {{2, 1}, {7, 3}, {5, 2}},
		["Quasihard"]        = {{5, 2}, {8, 3}, {3, 1}},
		["Parahard"]         = {{3, 1}, {7, 2}, {4, 1}},
		["Ultrahard"]        = {{4, 1}, {5, 1}, {6, 1}},
		["Hard-of-basic"]    = {{2, 1}, {3, 1}, {4, 1}},
	}
	
	if unparsed == "" then
		parsed = lookup_table["Simple Tunings"]
	elseif unparsed == "NONE" then
		parsed = {}
	else
		parsed = lookup_table[unparsed] or tip.parse_numeric_pairs(unparsed)
	end
	return parsed
end

-- Parse JI ratios passed into template
-- If the unparsed string corresponds to a list of JI ratios ("a/b; c/d; e/f"),
-- then parse it as a list of ratios. If it's not that, parse it as search
-- args. If the text is "NONE", then there should be no ratios passed in.
-- If the unparsed string is an empty string, return nil. (This is so the
-- wrapper function can go by default search args.)
function p.parse_ji_ratios(unparsed, equave)
	local ratios = nil
	local search_args = nil
	
	if unparsed == "" then
		search_args = {["Int Limit"] = 50, ["Tenney Height"] = 8; ["Complements Only"] = true}		-- Defualt search args if no args were passed in
		ratios = jira.search_by_args_within_equave(equave, search_args)
	elseif unparsed == "NONE" then
		search_args = {}
		ratios = {}
	elseif string.match(unparsed, "Int Limit:") then
		search_args = jira.parse_search_args(unparsed)	-- Search requires at the absolute least an int limit, so see if there's "Int Limit"
		ratios = jira.search_by_args_within_equave(equave, search_args)
	else
		search_args = {}
		ratios = jira.parse_ratios(unparsed)
	end
	
	return ratios, jira.search_footnotes(search_args)
end

-- Wrapper function; to be called by template
function p.mos_tunings(frame)
	-- Get params
	local scalesig     = frame.args["Scale Signature"]
	local input_mos    = mos.parse(scalesig)
	local mos_prefix   = tamnams.verify_prefix(input_mos, frame.args["MOS Prefix"])
	local mos_abbrev   = tamnams.verify_abbrev(input_mos, frame.args["MOS Abbrev"])
	local is_collapsed = yesno(frame.args["Collapsed"], false)
	local step_ratios  = p.parse_step_ratios(frame.args["Step Ratios"])
	local tolerance    = tonumber(frame.args["Tolerance"])
	
	local ji_ratios, footnotes = p.parse_ji_ratios(frame.args["JI Ratios"], input_mos.equave)

	return p._mos_tunings(input_mos, mos_prefix, mos_abbrev, step_ratios, ji_ratios, tolerance, footnotes, is_collapsed)
end

function p.tester()
	local range, ratios = p.preprocess_step_ratios({{7, 1}, {3, 1}, {2, 1}})
	local input_mos = mos.parse("9L 4s<7/2>")
	
	--return p.preprocess_ji_ratios(input_mos, mos.modal_union(input_mos), {{2,1}, {3,2}, {5,3}}, ji_ratios)
	--return ji_ratios
	return p._mos_tunings(input_mos)
end

return p