Module:TAMNAMS

From Xenharmonic Wiki
Jump to navigation Jump to search

Documentation transcluded from /doc
Icon-Todo.png Todo: Documentation

-- Module for TAMNAMS-related things as it pertains to mosses
-- This module is meant to be used with other modules, not as part of a template
local mos = require("Module:MOS")
local rat = require("Module:Rational")
local p = {}

-- This module should reflect current TAMNAMS standards:
-- - Names for step ratios and ranges (soft, hard, etc)
-- - Extended step ratio ranges
-- - Naming for intervals and scale degrees (M2ms and M2md)
-- - Naming for modes (basically UDP)
-- - Naming for select scales

-- TODO?
-- - Function to parse a UDP and (possibly) scale degrees.
-- - Separate interval/degree lookup into separate functions for for abbrevs and
--   non-abbrev formats.
-- - Added arbitrary hardness lookup for a single ratio (EG, passing in 13:8
--   would return "quasisoft".

--------------------------------------------------------------------------------
--------------------------- NAME LOOKUP TABLES ---------------------------------
--------------------------------------------------------------------------------

-- Lookup table for tamnams step ratios
p.step_ratios = {
	["1:1"] = "equalized",
	["4:3"] = "supersoft",
	["3:2"] = "soft",
	["5:3"] = "semisoft",
	["2:1"] = "basic",
	["5:2"] = "semihard",
	["3:1"] = "hard",
	["4:1"] = "superhard",
	["1:0"] = "collapsed"
}

-- And step ratio ranges
p.step_ratio_ranges = {
	["1:1 to 2:1"] = "soft-of-basic",
	["1:1 to 3:2"] = "soft",
	["1:1 to 4:3"] = "ultrasoft",
	["4:3 to 3:2"] = "parasoft",
	["3:2 to 2:1"] = "hyposoft",
	["3:2 to 5:3"] = "quasisoft",
	["5:3 to 2:1"] = "minisoft",
	["2:1 to 5:2"] = "minihard",
	["5:2 to 3:1"] = "quasihard",
	["2:1 to 3:1"] = "hypohard",
	["3:1 to 4:1"] = "parahard",
	["4:1 to 1:0"] = "ultrahard",
	["3:1 to 1:0"] = "hard",
	["2:1 to 1:0"] = "hard-of-basic"
}

-- Lookup table for tamnams extended step ratios
p.step_ratios_ext = {
	["1:1"] = "equalized",
	["6:5"] = "semiequalized",
	["4:3"] = "supersoft",
	["3:2"] = "soft",
	["5:3"] = "semisoft",
	["2:1"] = "basic",
	["5:2"] = "semihard",
	["3:1"] = "hard",
	["4:1"] = "superhard",
	["6:1"] = "extrahard",
	["10:1"] = "semicollapsed",
	["1:0"] = "collapsed"
}

-- And extended step ratio ranges
p.step_ratio_ranges_ext = {
	["1:1 to 2:1"] = "soft-of-basic",
	["1:1 to 3:2"] = "soft",
	["1:1 to 6:5"] = "pseudoequalized",
	["6:5 to 4:3"] = "ultrasoft",
	["4:3 to 3:2"] = "parasoft",
	["3:2 to 2:1"] = "hyposoft",
	["3:2 to 5:3"] = "quasisoft",
	["5:3 to 2:1"] = "minisoft",
	["2:1 to 5:2"] = "minihard",
	["5:2 to 3:1"] = "quasihard",
	["2:1 to 3:1"] = "hypohard",
	["3:1 to 4:1"] = "parahard",
	["4:1 to 6:1"] = "hyperhard",
	["6:1 to 10:1"] = "clustered",
	["4:1 to 10:1"] = "ultrahard",
	["10:1 to 1:0"] = "pseudocollapsed",
	["3:1 to 1:0"] = "hard",
	["2:1 to 1:0"] = "hard-of-basic"
}

-- Lookup table for tamnams names within the range of 6-10 steps
p.mos_names = {
	["1L 1s"] = "monowood",
	["2L 2s"] = "biwood",
	["1L 5s"] = "antimachinoid",
	["2L 4s"] = "malic",
	["3L 3s"] = "triwood",
	["4L 2s"] = "citric",
	["5L 1s"] = "machinoid",
	["1L 6s"] = "onyx",
	["2L 5s"] = "antidiatonic",
	["3L 4s"] = "mosh",
	["4L 3s"] = "smitonic",
	["5L 2s"] = "diatonic",
	["6L 1s"] = "archaeotonic",
	["1L 7s"] = "antipine",
	["2L 6s"] = "subaric",
	["3L 5s"] = "checkertonic",
	["4L 4s"] = "tetrawood",
	["5L 3s"] = "oneirotonic",
	["6L 2s"] = "ekic",
	["7L 1s"] = "pine",
	["1L 8s"] = "antisubneutralic",
	["2L 7s"] = "balzano",
	["3L 6s"] = "tcherepnin",
	["4L 5s"] = "gramitonic",
	["5L 4s"] = "semiquartal",
	["6L 3s"] = "hyrulic",
	["7L 2s"] = "armotonic",
	["8L 1s"] = "subneutralic",
	["1L 9s"] = "antisinatonic",
	["2L 8s"] = "jaric",
	["3L 7s"] = "sephiroid",
	["4L 6s"] = "lime",
	["5L 5s"] = "pentawood",
	["6L 4s"] = "lemon",
	["7L 3s"] = "dicoid",
	["8L 2s"] = "taric",
	["9L 1s"] = "sinatonic"
}

-- And prefixes
p.mos_prefixes = {
	["1L 1s"] = "monwd",
	["2L 2s"] = "biwd",
	["1L 5s"] = "amech",
	["2L 4s"] = "mal",
	["3L 3s"] = "triwd",
	["4L 2s"] = "citro",
	["5L 1s"] = "mech",
	["1L 6s"] = "on",
	["2L 5s"] = "pel",
	["3L 4s"] = "mosh",
	["4L 3s"] = "smi",
	["5L 2s"] = "dia",
	["6L 1s"] = "arch",
	["1L 7s"] = "apine",
	["2L 6s"] = "subar",
	["3L 5s"] = "check",
	["4L 4s"] = "tetrawd",
	["5L 3s"] = "oneiro",
	["6L 2s"] = "ek",
	["7L 1s"] = "pine",
	["1L 8s"] = "ablu",
	["2L 7s"] = "bal",
	["3L 6s"] = "cher",
	["4L 5s"] = "gram",
	["5L 4s"] = "cthon",
	["6L 3s"] = "hyru",
	["7L 2s"] = "arm",
	["8L 1s"] = "blu",
	["1L 9s"] = "asina",
	["2L 8s"] = "jara",
	["3L 7s"] = "seph",
	["4L 6s"] = "lime",
	["5L 5s"] = "pentawd",
	["6L 4s"] = "lem",
	["7L 3s"] = "dico",
	["8L 2s"] = "tara",
	["9L 1s"] = "sina"
}

-- And abbrevs
p.mos_abbrevs = {
	["1L 1s"] = "w",
	["2L 2s"] = "bw",
	["1L 5s"] = "amk",
	["2L 4s"] = "mal",
	["3L 3s"] = "tw",
	["4L 2s"] = "cit",
	["5L 1s"] = "mk",
	["1L 6s"] = "on",
	["2L 5s"] = "pel",
	["3L 4s"] = "mosh",
	["4L 3s"] = "smi",
	["5L 2s"] = "dia",
	["6L 1s"] = "arc",
	["1L 7s"] = "ap",
	["2L 6s"] = "sb",
	["3L 5s"] = "chk",
	["4L 4s"] = "ttw",
	["5L 3s"] = "onei",
	["6L 2s"] = "ek",
	["7L 1s"] = "p",
	["1L 8s"] = "ablu",
	["2L 7s"] = "bz",
	["3L 6s"] = "ch",
	["4L 5s"] = "gm",
	["5L 4s"] = "ct",
	["6L 3s"] = "hy",
	["7L 2s"] = "arm",
	["8L 1s"] = "blu",
	["1L 9s"] = "asi",
	["2L 8s"] = "ja",
	["3L 7s"] = "sp",
	["4L 6s"] = "lm",
	["5L 5s"] = "pw",
	["6L 4s"] = "le",
	["7L 3s"] = "di",
	["8L 2s"] = "ta",
	["9L 1s"] = "si"
}

-- TAMNAMS equave-agnostic names
p.equave_agnostic_names = {
	["1L 1s"] = "trivial",
	["1L 2s"] = "antrial",
	["2L 1s"] = "trial",
	["1L 3s"] = "antetric",
	["3L 1s"] = "tetric",
	["1L 4s"] = "pedal",
	["2L 3s"] = "pentic",
	["3L 2s"] = "anpentic",
	["4L 1s"] = "manual"
}

-- And prefixes
p.equave_agnostic_prefixes = {
	["1L 1s"] = "trv",
	["1L 2s"] = "at",
	["2L 1s"] = "t",
	["1L 3s"] = "att",
	["3L 1s"] = "tt",
	["1L 4s"] = "pd",
	["2L 3s"] = "pt",
	["3L 2s"] = "apt",
	["4L 1s"] = "mnu"
}

-- And abbrevs
p.equave_agnostic_abbrevs = {
	["1L 1s"] = "trv",
	["1L 2s"] = "at",
	["2L 1s"] = "t",
	["1L 3s"] = "att",
	["3L 1s"] = "tt",
	["1L 4s"] = "ped",
	["2L 3s"] = "pt",
	["3L 2s"] = "apt",
	["4L 1s"] = "mnu"
}

--------------------------------------------------------------------------------
------------------------------ HELPER FUNCTIONS --------------------------------
--------------------------------------------------------------------------------

-- Step ratios are entered as an array of two numeric values, or alternatively,
-- as a ratio as defined by the rational module. If of the former, this helper
-- function converts it to the latter. This preprocess step is for simplifying
-- ratios.
function p.preprocess_step_ratio(step_ratio)
	if type(step_ratio) == "string" then
		return step_ratio
	elseif (type(step_ratio) == "table" and type(step_ratio[1]) == "number" and type(step_ratio[2]) == "number") then
		return rat.new(step_ratio[1], step_ratio[2])
	else
		return nil
	end
end

-- Mosses for name lookup are entered either as a scalesig or as a mos as
-- defined in the mos module. If of the latter, it's converted into a textual
-- scalesig.
function p.preprocess_scalesig(input_mos)
	if type(input_mos) == "string" then
		return input_mos
	elseif type(input_mos) == "table" then
		return mos.as_string(input_mos)
	else
		return nil
	end
end

--------------------------------------------------------------------------------
------------------------ TEMPLATE HELPER FUNCTIONS -----------------------------
--------------------------------------------------------------------------------

-- Verifier function that checks for a prefix already provided by TAMNAMS.
-- If there is one provided, use that. If there is none, use the one passed in.
-- If no prefix is provided, defualt to "mos". If "NONE" is entered, return an
-- empty string.
function p.verify_prefix(input_mos, mos_prefix)
	local mos_prefix = p.lookup_prefix(input_mos) or mos_prefix or "mos"
	if mos_prefix == "NONE" or mos_prefix == "none" then 
		mos_prefix = ""
	elseif mos_prefix == "" then
		mos_prefix = "mos"
	end
	return mos_prefix
end

-- Verifier function that checks for an abbrev already provided by TAMNAMS.
-- If there is one provided, use that. If there is none, use the one passed in.
-- If no abbrev is provided, defualt to "m". If "NONE" is entered, return an
-- empty string.
function p.verify_abbrev(input_mos, mos_abbrev)
	local mos_abbrev = p.lookup_abbrev(input_mos) or mos_abbrev or "m"
	if mos_abbrev == "NONE" or mos_abbrev == "none" then 
		mos_abbrev = ""
	elseif mos_abbrev == "" then
		mos_abbrev = "m"
	end
	return mos_abbrev
end

--------------------------------------------------------------------------------
------------------------- NAME LOOKUP FUNCTIONS --------------------------------
--------------------------------------------------------------------------------

-- Function for looking up a mos's name (octave-equivalent mosses only).
-- Can accept either a mos (defined by mos module) or its scalesig.
function p.lookup_name(input_mos) 
	local scalesig = p.preprocess_scalesig(input_mos)
	return p.mos_names[scalesig]
end

-- Function for looking up a mos's prefix (octave-equivalent mosses only).
-- Can accept either a mos (defined by mos module) or its scalesig.
function p.lookup_prefix(input_mos)
	local scalesig = p.preprocess_scalesig(input_mos)
	return p.mos_prefixes[scalesig]
end

-- Function for looking up a mos's abbrev (octave-equivalent mosses only).
-- Can accept either a mos (defined by mos module) or its scalesig.
function p.lookup_abbrev(input_mos) 
	local scalesig = p.preprocess_scalesig(input_mos)
	return p.mos_abbrevs[scalesig]
end

-- Function for looking up a step ratio range
-- Module:Rational is used to help simplify ratios
function p.lookup_step_ratio(step_ratio, use_extended)
	local step_ratio = p.preprocess_step_ratio(step_ratio)
	local use_extended = use_extended == true
	
	-- Produce the key needed to lookup the step ratio name
	-- use_extended is used to toggle between central range and extended range
	local key = rat.as_ratio(step_ratio, ":")
	local named_ratio = use_extended and p.step_ratios_ext[key] or p.step_ratios[key]
	
	return named_ratio
end

-- Function for looking up a step ratio range
-- Module:Rational is used to help simplify ratios
function p.lookup_step_ratio_range(step_ratio_1, step_ratio_2, use_extended)
	local step_ratio_1 = p.preprocess_step_ratio(step_ratio_1)
	local step_ratio_2 = p.preprocess_step_ratio(step_ratio_2)
	local use_extended = use_extended == true
	
	-- Produce the key needed for the lookup table as a/b to c/d
	-- Swap ratios if ratio 1 has a higher hardness than ratio 2
	local key = ""
	local float_1 = rat.as_float(step_ratio_1)
	local float_2 = rat.as_float(step_ratio_2)
	if (float_1 > float_2) then
		key = string.format("%s to %s", rat.as_ratio(step_ratio_2, ":"), rat.as_ratio(step_ratio_1, ":"))
	else
		key = string.format("%s to %s", rat.as_ratio(step_ratio_1, ":"), rat.as_ratio(step_ratio_2, ":"))
	end
	
	-- use_extended is used to toggle between central range and extended range
	local named_ratio_range = use_extended and p.step_ratio_ranges_ext[key] or p.step_ratio_ranges[key]
	
	return named_ratio_range
end

--------------------------------------------------------------------------------
------------------------- NAME FINDER FUNCTIONS --------------------------------
--------------------------------------------------------------------------------

-- Helper function
-- "Rounds" step ratios up to the nearest named ratio
function p.step_ratio_ceil(step_ratio)
	local hardness = step_ratio[1] / step_ratio[2]
	
	local rounded_step_ratio = nil
	if hardness > 1/1 and hardness <= 4/3 then
		rounded_step_ratio = {4,3}
	elseif hardness > 4/3 and hardness <= 3/2 then
		rounded_step_ratio = {3,2}
	elseif hardness > 3/2 and hardness <= 5/3 then
		rounded_step_ratio = {5,3}
	elseif hardness > 5/3 and hardness <= 2/1 then
		rounded_step_ratio = {2,1}
	elseif hardness > 2/1 and hardness <= 5/2 then
		rounded_step_ratio = {5,2}
	elseif hardness > 5/2 and hardness <= 3/1 then
		rounded_step_ratio = {3,1}
	elseif hardness > 3/1 and hardness <= 4/1 then
		rounded_step_ratio = {4,1}
	elseif hardness > 4/1 and hardness <= 1/0 then
		rounded_step_ratio = {1,0}
	end
	return rounded_step_ratio
end

-- Helper function
-- "Rounds" step ratios down to the nearest named ratio
function p.step_ratio_floor(step_ratio)
	local hardness = step_ratio[1] / step_ratio[2]
	
	local rounded_step_ratio = nil
	if hardness >= 1/1 and hardness < 4/3 then
		rounded_step_ratio = {1,1}
	elseif hardness >= 4/3 and hardness < 3/2 then
		rounded_step_ratio = {4,3}
	elseif hardness >= 3/2 and hardness < 5/3 then
		rounded_step_ratio = {3,2}
	elseif hardness >= 5/3 and hardness < 2/1 then
		rounded_step_ratio = {5,3}
	elseif hardness >= 2/1 and hardness < 5/2 then
		rounded_step_ratio = {2,1}
	elseif hardness >= 5/2 and hardness < 3/1 then
		rounded_step_ratio = {5,2}
	elseif hardness >= 3/1 and hardness < 4/1 then
		rounded_step_ratio = {3,1}
	elseif hardness >= 4/1 and hardness < 1/0 then
		rounded_step_ratio = {4,1}
	end
	return rounded_step_ratio
end

-- Function for finding the smallest step ratio range that encompasses the two
-- ratios passed in.
function p.find_step_ratio_range_for_ratio_pair(step_ratio_1, step_ratio_2, use_extended)
	local use_extended = use_extended == true		-- Does nothing for now
	
	-- Swap ratios so they're in the right order
	local hardness_1 = step_ratio_1[1] / step_ratio_1[2]
	local hardness_2 = step_ratio_2[1] / step_ratio_2[2]
	local lower_ratio = nil
	local upper_ratio = nil
	local named_ratio_range = ""
	if hardness_1 <= hardness_2 then
		lower_ratio = p.step_ratio_floor(step_ratio_1)
		upper_ratio = p.step_ratio_ceil (step_ratio_2)
	else 
		lower_ratio = p.step_ratio_floor(step_ratio_2)
		upper_ratio = p.step_ratio_ceil (step_ratio_1)
	end
	
	-- If one ratio corresponds to the endpoint of a named hardness range
	-- but the other ratio exceeds that of a smaller range, default to the
	-- largest range that would accommodate it.
	-- 2:1 to (L:s > 3:1) = hard-of-basic
	-- 4:1 and up = ultrahard
	-- (L:s < 3:2) to 2:1 = soft-of-basic
	-- 4:3 and lower = ultrasoft
	if (lower_ratio[1]/lower_ratio[2] == 2/1 and upper_ratio[1]/upper_ratio[2] > 3/1) or lower_ratio[1]/lower_ratio[2] == 4/1 then
		upper_ratio = {1,0}
	elseif (upper_ratio[1]/upper_ratio[2] == 2/1 and lower_ratio[1]/lower_ratio[2] < 3/2) or upper_ratio[1]/upper_ratio[2] == 4/3 then
		lower_ratio = {1,1}
	end

	local named_ratio_range = p.lookup_step_ratio_range(lower_ratio, upper_ratio, use_extended)
	return named_ratio_range
end

-- Given a mos, find the ancestor mos within the target note count.
function p.find_ancestor(input_mos, target_note_count)
	local target_note_count = target_note_count or 10
	
	local z = input_mos.nL
	local w = input_mos.ns
	
	while (z ~= w) and (z + w > target_note_count) do
		local m1 = math.max(z, w)
		local m2 = math.min(z, w)
		
		-- For use with updating ancestor mos chunks
		local z_prev = z
		
		-- Update step ratios
		z = m2
		w = m1 - m2
	end
	
	return mos.new(z, w, input_mos.equave)
end

-- Given a mos, find the ancestor mos within the target note count, while also
-- returning the step ancestor's step ratio range (as two ratios) and the number
-- of generations between the two mosses. (A more in-depth version of the prev.)
function p.find_ancestor_info(input_mos, target_step_count)
	local target_step_count = target_step_count or 10
	
	-- For an ancestor mos zU wv and descendant xL ys, how many steps of size
	-- L and s can fit inside U and v? (basically the chunking operation)
	local z = input_mos.nL
	local w = input_mos.ns
	local lg_chunk = { nL = 1, ns = 0 }
	local sm_chunk = { nL = 0, ns = 1 }
	local generations = 0

	while (z ~= w) and (z + w > target_step_count) do
		local m1 = math.max(z, w)
		local m2 = math.min(z, w)
		
		-- For use with updating ancestor mos chunks
		local z_prev = z
		
		-- Count how many generations
		generations = generations + 1
		
		-- Update step ratios
		z = m2
		w = m1 - m2
		
		-- Update large chunk
		local prev_lg_chunk = { nL = lg_chunk.nL, ns = lg_chunk.ns }
		lg_chunk.nL = lg_chunk.nL + sm_chunk.nL
		lg_chunk.ns = lg_chunk.ns + sm_chunk.ns
		
		-- Update small chunk
		if z ~= z_prev then
			sm_chunk = prev_lg_chunk
		end
	end
	
	-- Translate chunks into step ratios
	local num1 = lg_chunk.nL + lg_chunk.ns
	local den1 = sm_chunk.nL + sm_chunk.ns
	local num2 = lg_chunk.nL
	local den2 = sm_chunk.nL
	local ratio_1, ratio_2
	if num1/den1 < num2/den2 then
		ratio_1 = { num1, den1 }
		ratio_2 = { num2, den2 }
	else
		ratio_2 = { num1, den1 }
		ratio_1 = { num2, den2 }
	end
	
	return mos.new(z, w, input_mos.equave), ratio_1, ratio_2, generations
end

--------------------------------------------------------------------------------
--------------------- MOSSTEP/MOSDEGREE QUALITY FUNCTIONS ----------------------
--------------------------------------------------------------------------------

-- Given an interval vector for a mos, produce the name for that interval.
-- Prefix lookup is done automatically if no prefix is provided; defaults to
-- "mos" if no prefix is found. Prefixes are used for the full name for an
-- interval, whereas abbrevs are used for the abbreviated form. (This is
-- because some abbrevs are shorter than the corresponding prefix.)
-- Formats are as follows:
-- - NONE: full name, eg "perfect 4-diastep"
-- - SENTENCE-CASE: same as NONE, but with capitalized first letter
-- - SHORTENED: shortened form, eg "Perf. 4-diastep"
-- - ABBREV: abbreviated form, eg "P4dias"
function p.interval_quality(interval, input_mos, abbrev_format, mos_prefix)
	local abbrev_format = abbrev_format or "none"
	local mos_prefix = mos_prefix 
		or (abbrev_format == "abbrev" and p.lookup_abbrev(input_mos) or p.lookup_prefix(input_mos))
		or (abbrev_format == "abbrev" and "m" or "mos")
	
	-- Get the step count of the interval. The sum of L's and s's will always
	-- determine what k-mosstep the interval is.
	local step_count = mos.interval_step_count(interval)
	
	-- Decode the quality
	local quality = p.decode_quality(interval, input_mos, abbrev_format)
	
	if abbrev_format == "abbrev" or abbrev_format == "ABBREV" then
		return string.format("%s%d%ss", quality, step_count, mos_prefix)
	elseif abbrev_format == "shortened" or abbrev_format == "SHORTENED" then
		return string.format("%s %d-%ss.", quality, step_count, mos_prefix)
	elseif abbrev_format == "sentence-case" or abbrev_format == "SENTENCE-CASE" then
		return string.format("%s %d-%sstep", quality, step_count, mos_prefix)
	else
		return string.format("%s %d-%sstep", quality, step_count, mos_prefix)
	end
end

-- Given an interval vector for a mos, produce the name for the scale degree
-- reached by going up that interval, from the root. (This is identical to the
-- previous function, except it uses "degree" instead of "step".)
-- Prefix lookup is done automatically, as with interval_quality().
-- Formats are as follows:
-- - NONE: full name, eg "perfect 4-diadegree"
-- - SENTENCE-CASE: same as NONE, but with capitalized first letter
-- - SHORTENED: shortened form, eg "Perf. 4-diadegree"
-- - ABBREV: abbreviated form, eg "P4diad"
function p.degree_quality(interval, input_mos, abbrev_format, mos_prefix)
	local abbrev_format = abbrev_format or "none"
	local mos_prefix = mos_prefix 
		or (abbrev_format == "abbrev" and p.lookup_abbrev(input_mos) or p.lookup_prefix(input_mos))
		or (abbrev_format == "abbrev" and "m" or "mos")
	
	-- Get the step count of the interval. The sum of L's and s's will always
	-- determine what k-mosstep the interval is.
	local step_count = mos.interval_step_count(interval)
	
	-- Decode the quality
	local quality = p.decode_quality(interval, input_mos, abbrev_format)
	
	if abbrev_format == "abbrev" or abbrev_format == "ABBREV" then
		return string.format("%s%d%sd", quality, step_count, mos_prefix)
	elseif abbrev_format == "shortened" or abbrev_format == "SHORTENED" then
		return string.format("%s %d-%sd.", quality, step_count, mos_prefix)
	elseif abbrev_format == "sentence-case" or abbrev_format == "SENTENCE-CASE" then
		return string.format("%s %d-%sdegree", quality, step_count, mos_prefix)
	else
		return string.format("%s %d-%sdegree", quality, step_count, mos_prefix)
	end
end

-- Decodes the quality of a mosstep. Helper function to interval_quality() and
-- degree_quality(), but can be used standalone if only the keyword (maj, min,
-- aug, perf, dim) is needed. The chroma amounts are as follows:
-- AMT| PERFECTABLE		| NONPERFECTABLE	| DARK GEN ONLY
-- ---+-----------------+-------------------+------------------
-- ...|    . . .        |    . . .          |    . . .
--  4 | 4x augmented	| 4x augmented		| 5x augmented
--  3 | 3x augmented	| 3x augmented		| 4x augmented
--  2 | 2x augmented	| 2x augmented		| 3x augmented
--  1 | augmented		| augmented			| 4x augmented
--  0 | perfect			| major				| augmented
-- -1 | diminished		| minor				| perfect
-- -2 | 2x diminished	| diminished		| diminished
-- -3 | 3x diminished	| 2x diminished		| 2x diminished
-- -4 | 4x diminished	| 3x diminished		| 3x diminished
-- -5 | 5x diminished	| 4x diminished		| 4x diminished
-- ...|    . . .        |    . . .          |    . . .
function p.decode_quality(interval, input_mos, abbrev_format)
	local abbrev_format = abbrev_format or "none"		-- Default is no abbreviation

	-- Normalize the interval so negative values aren't being used.
	local interval = mos.normalize_interval(interval)
	
	-- Get the step count of the interval. The sum of L's and s's will always
	-- determine what k-mosstep the interval is.
	local step_count = mos.interval_step_count(interval)
	
	-- Determine what "special" type the interval is so that the designations
	-- of augmented/perfect/diminished (APd) apply, skipping major/minor (Mm).
	-- If it's the period or equave, then it's a multiple of the period. 
	-- If it's any one of the gens, then reducing it should produce that gen.
	local is_period     = step_count % mos.period_step_count(input_mos) == 0
	local is_bright_gen = step_count % mos.period_step_count(input_mos) == mos.bright_gen_step_count(input_mos)
	local is_dark_gen   = step_count % mos.period_step_count(input_mos) == mos.dark_gen_step_count(input_mos)
	
	-- Special case: APd does not apply to a root mos's (nL ns) generators;
	-- instead, it's Mm.
	local is_root_mos = input_mos.nL == input_mos.ns
	
	-- Is perfectable? This is for intervals for which maj/min does not apply.
	local is_perfectable = is_period or (is_bright_gen and not is_root_mos) or (is_dark_gen and not is_root_mos)
	
	-- Get chroma count and adjust as needed
	local chroma_count = 0
	if is_period then
		-- Chroma count 0 is the perfect size. This interval does not appear
		-- as any other size across all mos modes.
		chroma_count = mos.interval_chroma_count(interval, input_mos)
	elseif is_bright_gen and not is_root_mos then
		-- Chroma count 0 is the large size, and -1 the small size; these
		-- are perfect and diminished respectively.
		chroma_count = mos.interval_chroma_count(interval, input_mos)
	elseif is_dark_gen and not is_root_mos then
		-- Chroma count 0 is the large size, and -1 the small size; these
		-- are augmented and perfect respectively. Since the perfect size
		-- corresponds to a chroma count of -1, pass in -1 as the 3rd arg.
		chroma_count = mos.interval_chroma_count(interval, input_mos, -1)
	else
		-- Chroma count 0 is the large size, and -1 the small size; these are
		-- major and minor respectively.
		chroma_count = mos.interval_chroma_count(interval, input_mos)
	end
	
	-- Get absolute value of chroma count
	local chroma_abs = math.abs(chroma_count)
	
	local quality = ""
	if is_perfectable then
		-- Get the quality for perfectable intervals
		
		if abbrev_format == "none" or abbrev_format == "NONE" then
			if chroma_count < 0 then
				quality = "diminished"
			elseif chroma_count > 0 then
				quality = "augmented"
			else
				quality = "perfect"
			end
			
			if chroma_abs > 1 then
				quality = string.format("%d× %s", chroma_abs, quality)
			end
		elseif abbrev_format == "sentence-case" or abbrev_format == "SENTENCE-CASE" then
			if chroma_count < 0 then
				quality = "Diminished"
			elseif chroma_count > 0 then
				quality = "Augmented"
			else
				quality = "Perfect"
			end
			
			if chroma_abs > 1 then
				quality = string.format("%d× %s", chroma_abs, quality)
			end
		elseif abbrev_format == "shortened" or abbrev_format == "SHORTENED" then
			if chroma_count < 0 then
				quality = "Dim."
			elseif chroma_count > 0 then
				quality = "Aug."
			else
				quality = "Perf."
			end
			
			if chroma_abs > 1 then
				quality = string.format("%d× %s", chroma_abs, quality)
			end
		elseif abbrev_format == "abbrev" or abbrev_format == "ABBREV" then
			if chroma_count < 0 then
				quality = "d"
			elseif chroma_count > 0 then
				quality = "A"
			else
				quality = "P"
			end
			
			if chroma_abs > 3 then
				quality = string.format("%s<sup>%d</sup>", quality, chroma_abs)
			elseif chroma_abs > 1 and chroma_abs <= 3 then
				quality = string.rep(quality, chroma_abs)
			end
		end
	else
		-- Get the quality for nonperfectable intervals
		
		-- Is the interval major? If not, decrement chroma_abs by 1
		local is_positive = chroma_count >= 0
		chroma_abs = is_positive and chroma_abs or chroma_abs - 1
		
		if abbrev_format == "none" or abbrev_format == "NONE" then
			if chroma_abs > 0 and is_positive then
				quality = "augmented"
			elseif chroma_abs > 0 and not is_positive then
				quality = "diminished"
			else
				quality = is_positive and "major" or "minor"
			end
			
			if chroma_abs > 1 then
				quality = string.format("%d× %s", chroma_abs, quality)
			end
		elseif abbrev_format == "sentence-case" or abbrev_format == "SENTENCE-CASE" then
			if chroma_abs > 0 and is_positive then
				quality = "Augmented"
			elseif chroma_abs > 0 and not is_positive then
				quality = "Diminished"
			else
				quality = is_positive and "Major" or "Minor"
			end
			
			if chroma_abs > 1 then
				quality = string.format("%d× %s", chroma_abs, quality)
			end
		elseif abbrev_format == "shortened" or abbrev_format == "SHORTENED" then
			if chroma_abs > 0 and is_positive then
				quality = "Aug."
			elseif chroma_abs > 0 and not is_positive then
				quality = "Dim."
			else
				quality = is_positive and "Maj." or "Min."
			end
			
			if chroma_abs > 1 then
				quality = string.format("%d× %s", chroma_abs, quality)
			end
		elseif abbrev_format == "abbrev" or abbrev_format == "ABBREV" then
			if chroma_abs > 0 and is_positive then
				quality = "A"
			elseif chroma_abs > 0 and not is_positive then
				quality = "d"
			else
				quality = is_positive and "M" or "m"
			end
			
			if chroma_abs > 3 then
				quality = string.format("%s<sup>%d</sup>", quality, chroma_abs)
			elseif chroma_abs > 1 and chroma_abs <= 3 then
				quality = string.rep(quality, chroma_abs)
			end
		end
	end
	
	return quality
end

--------------------------------------------------------------------------------
-------------------- MODE NOTATION/COMPARISON FUNCTIONS ------------------------
-------------------------- BASED ON UDP NOTATION -------------------------------
--------------------------------------------------------------------------------

-- Given the number of gens up and down per period and a period count (u, d, and
-- p respectively), construct the UDP as a string, as up|dp(p) or u|d, for
-- multi-period and single-period respectively.
-- If only u is known, then d = p - u - 1.
-- If only d is known, then u = p - d - 1. (Basically, u + d + 1 = steps/p.)
-- A table of alterations can also be passed in, assuming they are already
-- written out (EG, d3md or, if notation is established, b4).
function p.udp_as_string(gens_up_per_period, gens_down_per_period, periods, alterations)
	local periods = periods or 1
	local alterations_as_string = ""
	if alterations ~= nil then
		for i = 1, #alterations do
			alterations_as_string = alterations_as_string .. " " .. alterations[i]
		end
	end
	if periods == 1 then
		return string.format("%s&#124;%s", gens_up_per_period, gens_down_per_period) .. alterations_as_string
	else
		return string.format("%s&#124;%s(%s)", gens_up_per_period * periods, gens_down_per_period * periods, periods) .. alterations_as_string
	end
end

-- Given an input mos, list the udps for each of its modes, listed in order of
-- decreasing brightness.
function p.mos_mode_udps(input_mos)
	local steps_per_period = mos.period_step_count(input_mos)
	local period_count = mos.period_count(input_mos)
	
	local udps = {}
	for i = 1, steps_per_period do
		local gens_up = steps_per_period - i
		local gens_down = steps_per_period - gens_up - 1
		
		local udp = p.udp_as_string(gens_up, gens_down, period_count)
		table.insert(udps, udp)
	end
	return udps
end

-- Given an input mos, list the cpos for each of its modes. The circular
-- permutation orderings are listed starting from the brightest mode.
-- Example with 5L 2s modes
--  MODE NAME  | UDP | CPO 
-- ------------+-----+-----
--  Lydian     | 6|0 | 1
--  Ionian     | 5|1 | 5
--  Mixolydian | 4|2 | 2
--  Dorian     | 3|3 | 6
--  Aeolian    | 2|4 | 3
--  Phrygian   | 1|5 | 7
--  Lociran    | 0|6 | 4
function p.mos_mode_cpos(input_mos)
	local steps_per_period = mos.period_step_count(input_mos)
	local period_count = mos.period_count(input_mos)
	local steps_per_bright_gen = mos.bright_gen_step_count(input_mos)
	
	local cpos = {}
	for i = 1, steps_per_period do
		local cpo = ((i - 1) * steps_per_bright_gen) % steps_per_period + 1
		table.insert(cpos, cpo)
	end
	return cpos
end

-- Given a string that represents a mode, return the udps for each of its
-- rotations. If the mode is a modmos, the closest mode and its alterations
-- are returned as a string.
-- NOTES:
-- - A period of repetition will always have as many modes as there are steps
--   in that period. If it were any less, then the true period of repetition
--   is a substring of that.
function p.mode_rotation_udps(input_mode, input_mos, mos_abbrev, use_brightest_mode_search)
	local use_brightest_mode_search = use_brightest_mode_search == nil or use_brightest_mode_search
	local modes = mos.mode_rotations(input_mode)
	
	local udps = {}
	for i = 1, #modes do
		table.insert(udps, p.mode_udp(modes[i], input_mos, mos_abbrev, use_brightest_mode_search))
	end
	return udps
end

-- Given a string that represents a mode, return its udp.
-- Helper function for mode_rotation_udps().
-- If a mode is for a modmos, it will return the closest brightest mode followed
-- by its altered scale degrees. Alterations require a mos abbrev, which is
-- automatically looked up, defaulting to "m" if no abbrev can be found.
-- NOTES:
-- - This is inefficient when used on true-mos modes since it's a brute-force
--   search for what mode it's closest to. It is, however, effective on
--   modmosses, which is the primary use for this function.
function p.mode_udp(input_mode, input_mos, mos_abbrev, use_brightest_mode_search)
	local use_brightest_mode_search = use_brightest_mode_search == nil or use_brightest_mode_search
	local mos_abbrev = mos_abbrev or p.lookup_abbrev(input_mos) or "m"
	local true_modes = mos.modes_to_step_matrices(input_mos)
	local input_mode_as_step_matrix = mos.mode_to_step_matrix(input_mode)
	
	-- For each mode, count the number of differences between each true mode
	-- and the entered mode and keep track of which mode has the fewest diffs.
	-- That mode is considered the closest mode, whose UDP will be used for the
	-- mode name, followed by which scale degrees are changed.
	-- If the number of diffs is ever zero, then the entered mode was a true-mos
	-- mode and has zero alterations.
	local lowest_differences = #input_mode_as_step_matrix
	local bright_gens_down_per_period = 0
	local differences = {}
	for i = 1, #true_modes do
		local current_true_mode = true_modes[i]
		local current_differences = p.differences_between_modes(current_true_mode, input_mode_as_step_matrix)
		
		-- It's possible for more than one mode to be closest. The tiebreaker is
		-- whichever mode is brightest (or darkest, which can be toggled).
		if use_brightest_mode_search then
			if #current_differences < lowest_differences then
				-- Brightest-closest match
				bright_gens_down_per_period = i - 1
				lowest_differences = #current_differences
				differences = current_differences
			end
		else
			if #current_differences <= lowest_differences then
				-- Darkest-closest match
				bright_gens_down_per_period = i - 1
				lowest_differences = #current_differences
				differences = current_differences
			end
		end
	end
	
	-- Parse the differences as scale degrees.
	-- The differences between the true mode and the input mode are denoted as
	-- interval vectors. These should be parsed into a list of altered scale
	-- degrees before being passed into udp_as_string. Coding it this way allows
	-- for the possibility of adding custom mos notation (EG, diamond-mos).
	local alterations = {}
	for i = 1, #differences do
		table.insert(alterations, p.degree_quality(differences[i], input_mos, "ABBREV", mos_abbrev))
	end
	
	-- Produce the UDP (as text) for the mode, formatted as up|dp(p) for multi-
	-- period mosses, or u|d for single-period mosses.
	local period_count = mos.period_count(input_mos)
	local bright_gens_up_per_period = mos.period_step_count(input_mos) - 1 - bright_gens_down_per_period
	udp = p.udp_as_string(bright_gens_up_per_period, bright_gens_down_per_period, period_count, alterations)
	
	return udp
end

-- Helper function for mode_udp, but can be used standalone.
-- Given two modes as step matrices, produce a list of differences between the
-- base mode and the altered mode. Diffs are listed as an array of mosstep
-- vectors (a table containing the number of L's and s's for that interval).
function p.differences_between_modes(base_step_matrix, altered_step_matrix)
	
	local differences = {}
	for i = 1, #altered_step_matrix do
		local altered_interval = altered_step_matrix[i]
		
		-- If i is greater than the number of intervals in base_step_matrix,
		-- then the interval being accessed is an extra-equave interval. Instead
		-- of accessing the ith interval (which would lead to a nil error),
		-- access the corresponding equave-reduced interval, then raise it by
		-- the necessary number of equaves.
		local base_interval = {}
		if i > #base_step_matrix then
			local current_mossteps = i - 1
			local equave_step_count = #base_step_matrix - 1
			local equave_interval = base_step_matrix[#base_step_matrix]
			
			local equave_reduced_interval = base_step_matrix[current_mossteps % equave_step_count + 1]
			local equave_count = math.floor(current_mossteps / equave_step_count)
			
			local equaves = mos.interval_mul(equave_interval, equave_count)
			base_interval = mos.interval_add(equave_reduced_interval, equaves)
		else
			base_interval = base_step_matrix[i]
		end
		
		if not mos.interval_eq(base_interval, altered_interval) then
			table.insert(differences, altered_interval)
		end
	end
	
	return differences
end

--------------------------------------------------------------------------------
----------------------------- TESTER FUNCTION ----------------------------------
--------------------------------------------------------------------------------

function p.tester()
	return ""
	.. p.find_step_ratio_range_for_ratio_pair({1,1},{2,1}) .. "\n"
	.. p.find_step_ratio_range_for_ratio_pair({1,1},{3,2}) .. "\n"
	.. p.find_step_ratio_range_for_ratio_pair({1,1},{4,3}) .. "\n"
	.. p.find_step_ratio_range_for_ratio_pair({4,3},{3,2}) .. "\n"
	.. p.find_step_ratio_range_for_ratio_pair({3,2},{2,1}) .. "\n"
	.. p.find_step_ratio_range_for_ratio_pair({3,2},{5,3}) .. "\n"
	.. p.find_step_ratio_range_for_ratio_pair({5,3},{2,1}) .. "\n"
	.. p.find_step_ratio_range_for_ratio_pair({2,1},{5,2}) .. "\n"
	.. p.find_step_ratio_range_for_ratio_pair({5,2},{3,1}) .. "\n"
	.. p.find_step_ratio_range_for_ratio_pair({2,1},{3,1}) .. "\n"
	.. p.find_step_ratio_range_for_ratio_pair({3,1},{4,1}) .. "\n"
	.. p.find_step_ratio_range_for_ratio_pair({4,1},{1,0}) .. "\n"
	.. p.find_step_ratio_range_for_ratio_pair({3,1},{1,0}) .. "\n"
	.. p.find_step_ratio_range_for_ratio_pair({2,1},{1,0}) .. "\n"
		
end

return p