Module:MOS modes

From Xenharmonic Wiki
Jump to navigation Jump to search

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

local mos = require('Module:MOS')
local rat = require('Module:Rational')
local utils = require('Module:Utils')
local p = {}

-- TODO: collapse table if the number of modes is, say, more than 12

-- Function that takes a mos and produces all of the modes by brightness
-- The mos is entered as a data structure provided by Module:MOS
function p.modes_by_brightness(input_mos)
	-- Default parameter, which corresponds to 5L 2s <2/1>
	local input_mos = input_mos or mos.new(5, 2, 2)
	
	-- Get the number of L's, s's, and periods
	local nL = input_mos.nL
	local ns = input_mos.ns
	local periods = utils._gcd(nL, ns)
	
	-- Find its brightest mode as a string of L's and s's
	local brightest_mode = mos.brightest_mode(input_mos)
	
	-- Find the size of the generator aned period in mossteps
	local gen = mos.bright_gen(input_mos)
	local gen_in_mossteps = gen['L'] + gen['s']
	local period_size  = round((nL + ns) / periods)
	
	-- For a mos xL ys, there are x+y unique modes that can be obtained by the following process:
	-- For a generator g in mossteps (g < x+y) and starting with the brightest mode (as a string
	-- of L's and s's), move the first g steps to the end to get the next-brightest mode.
	-- Repeat this process with the rotated string to get all modes. The x+y-1th time this is done
	-- will be the darkest mode.
	-- In the case of a multiperiod mos nxL nys, consider it as the mos for xL ys and duplicate
	-- each result n times. This way, the number of rotations needed to be performed is still x+y-1.
	local brightest_mode_substr = string.sub(brightest_mode, 1, period_size)
	local modes = { brightest_mode }
	local current_mode = brightest_mode_substr
	
	for i = 1, period_size - 1 do
		-- Move the first g characters from the beginning to the end
		local first_substr = string.sub(current_mode, 1, gen_in_mossteps)
		local second_substr = string.sub(current_mode, gen_in_mossteps + 1, period_size)
		current_mode = second_substr .. first_substr
		
		-- Duplicate the string (just in case) then add it to the array of modes
		local current_mode_duplicated = string.rep(current_mode, periods)
		table.insert(modes, current_mode_duplicated)
	end
	
	return modes
end

-- Helper function that parses entries from a semicolon-delimited string and returns them in an array
function p.parse_entries(unparsed)
	local parsed = {}
	for entry in string.gmatch(unparsed, '([^;]+)') do
		local trimmed = entry:gsub("^%s*(.-)%s*$", "%1")
		table.insert(parsed, trimmed)		-- Add to array
	end
	return parsed
end

-- "Main" function
-- To be called by wrapper
function p._mos_modes(input_mos, mode_names, headers, entries)
	-- Mos is entered as a scale signature "xL ys" or "xL ys<p/q>" since the mos module can parse that format
	local input_mos = input_mos or mos.parse(scale_sig)
	
	-- Get the mos's mode names, if given
	-- Mode names are entered as a semicolon-delimited list
	local mode_names = mode_names or {}
	
	-- Get the mos's modes and the mode count
	local mos_modes = p.modes_by_brightness(input_mos)
	local periods = utils._gcd(input_mos.nL, input_mos.ns)		-- Needed for UDP
	local mossteps_per_period = (input_mos.nL + input_mos.ns) / periods

	-- This is for entering multiple columns of info, if a single column of mode names isn't enough
	-- For n headers, the number of entries must match the number of modes times the number of headers
	-- or else column data won't be added
	local headers = headers or {}
	local entries = entries or {}

	-- To determine whether to add additional columns, determine whether the number of entries
	-- and the number of columns are greater than zero, and if so, determine whether the number of entries
	-- is equal to the number of headers times the number of modes
	local add_columns = #headers > 0 and #entries > 0
	if add_columns then
		add_columns = add_columns and #mos_modes * #headers == #entries
	end
	
	-- Get the number of mossteps in the bright gen
	-- Used for calculating rotational order
	local bright_gen = mos.bright_gen(input_mos)
	local mossteps_per_bright_gen = bright_gen['L'] + bright_gen['s']
	
	-- Make a table with a column for the mode (as a string of L's and s's) and UDP
	local result = '{| class="wikitable sortable"\n'
	result = result .. "|+ " .. "Modes of " .. mos.as_string(input_mos) .. "\n"		-- To create the scale signature with〈〉instead of <>
	result = result .. "|-\n"
	result = result .. "! [[UDP]]\n"
	result = result .. "! Rotational order\n"
	result = result .. "! Step pattern\n"
	
	-- If there are mode names (if the mode names array is not nil), then add a column for mode names
	if #mode_names == #mos_modes then
		result = result .. '! class="unsortable" | Mode names\n'
	end
	
	-- Add columns
	-- If mode names and columns are used, mode names come first
	if add_columns then
		for i = 1, #headers do
			result = result .. '! class="unsortable" |'	.. headers[i] .. "\n"
		end
	end
	
	-- Enter each row
	-- As of coding, mos mode listings are fairly inconsistent or nonexistent, but consist of
	-- the UDP, step pattern, and any mode names(s) in some order
	-- This table orders them as UDP, step pattern, and (TODO) mode names, as that's more common
	for i = 1, #mos_modes do
		result = result .. "|-\n"
		
		-- Add the UDP, formatted as up|dp(p), where u is the number of bright generators going up,
		-- d is the number of bright generators going down, and p is the number of periods
		-- Omit p if p = 1
		local gens_down = (i - 1) * periods
		local gens_up = (#mos_modes - i) * periods
		local udp_as_text = ""
		if periods == 1 then
			udp_as_text = gens_up .. '&#124;' .. gens_down
		else
			udp_as_text = gens_up .. '&#124;' .. gens_down .. "(" .. periods .. ")"
		end
		result = result .. "|" .. udp_as_text .. "\n"
		
		-- Add the mode's rotational order
		local bright_gens_up = mossteps_per_bright_gen * (i-1)
		local rotational_order = bright_gens_up % mossteps_per_period + 1
		result = result .. "|" .. rotational_order .. "\n"

		-- Add the mode's step pattern
		result = result .. "|" .. mos_modes[i] .. "\n"
		
		-- Add the mode's name, if given
		if #mode_names == #mos_modes  then
			result = result .. "|" .. mode_names[i] .. "\n"
		end
		
		-- Add columns if given
		if add_columns then
			for j = 1, #headers do
				local index = (i - 1) * #headers + j
				result = result .. "|" .. entries[index] .. "\n"
			end
		end
	end
	
	result = result .. "|}"
	
	return result
	
end

-- Wrapper function; to be called by template
function p.modes_table(frame)
	-- Mos is entered as a scale signature "xL ys" or "xL ys<p/q>" since the mos module can parse that format
	local scale_sig = frame.args['Scale Signature'] or "5L 2s"
	local input_mos = mos.parse(scale_sig)
	
	-- Get the mos's mode names, if given
	-- Mode names are entered as a semicolon-delimited list
	-- 5L 2s gets default names
	local mode_names = nil
	if scale_sig == "5L 2s" then
		mode_names_unparsed = "Lydian; Ionian (major); Mixolydian; Dorian; Aeolian (minor); Phrygian; Locrian"
		mode_names = p.parse_entries(mode_names_unparsed)
	else
		mode_names_unparsed = frame.args['Mode Names']
		mode_names = p.parse_entries(mode_names_unparsed)
	end

	-- This is for entering multiple columns of info, if a single column of mode names isn't enough
	-- For n headers, the number of entries must match the number of modes times the number of headers
	-- or else column data won't be added
	local headers_unparsed = frame.args['Table Headers']
	local headers = p.parse_entries(headers_unparsed)
	
	local entries_unparsed = frame.args['Table Entries']
	local entries = p.parse_entries(entries_unparsed)

	local result = p._mos_modes(input_mos, mode_names, headers, entries)
	
	return result
end

return p