Module:MOS gamut

From Xenharmonic Wiki
Jump to navigation Jump to search

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

local mos = require('Module:MOS')
local rat = require('Module:Rational')
local mosm = require('Module:MOS modes')
local et = require('Module:ET')
local mosnot = require('Module:MOS notation')
local utils = require('Module:Utils')
local p = {}

-- Function that produces a gamut, a sequence of note names with accidentals, for an edo
-- Helper function for the function that has "frame" as a parameter
function p.mos_gamut(input_mos, udp, step_ratio, note_symbols, chroma_plus_symbol, chroma_minus_symbol)
	-- Default parameters for input mos and step ratio (5L 2s and 2:1 step ratio)
	local input_mos = input_mos or mos.new(5, 2, 2)
	local step_ratio = step_ratio or { 2, 1 }
	
	-- Get the number of mossteps per period and equave
	local mossteps_per_equave = input_mos.nL + input_mos.ns
	local periods_per_equave = utils._gcd(input_mos.nL, input_mos.ns)
	local mossteps_per_period = mossteps_per_equave / periods_per_equave
	
	-- Some default params will be different if the scalesig is 5L 2s
	local scale_sig = mos.as_string(input_mos)
	
	-- The default UDP corresponds to the middle mode. For mosses with an even
	-- number of modes, there are two middle modes, so use the brighter of the
	-- two instead.
	-- If it's 5L 2s, default to the second-brightest mode.
	local udp_default = { periods_per_equave * math.ceil((mossteps_per_period - 1)/ 2), periods_per_equave * math.floor((mossteps_per_period - 1) / 2) }
	if scale_sig == "5L 2s" then
		udp_default = { 5, 1 }
	end
	local udp = udp or udp_default
	local generators_up = udp[1]
	local generators_down = udp[2]

	-- The natural note symbols are those that correspond to diamond-mos
	-- (JKLMN...) unless the mos is 5L 2s, then it's CDEFGAB
	-- If it's diamond-mos, gamut is limited to 17 note names
	local note_symbols_main = "JKLMNOPQRSTUVWXYZ"
	local note_symbols_default = string.sub(note_symbols_main, 1, mossteps_per_equave)
	if scale_sig == "5L 2s" then
		note_symbols_default = "CDEFGAB"
	end
	local note_symbols = note_symbols or note_symbols_default
	
	-- The default accidentals are the amp and at (& and @)
	-- unless the mos is 5L 2s, then it's sharp and flat (# and b)
	local chroma_plus_default = "&"
	local chroma_minus_default = "@"
	if scale_sig == "5L 2s" then
		chroma_plus_default = "#"
		chroma_minus_default = "b"
	end
	local chroma_plus_symbol = chroma_plus_symbol or chroma_plus_default
	local chroma_minus_symbol = chroma_minus_symbol or chroma_minus_default
	
	-- How long is the inital genchain for notes without accidentals?
	local gens_up_per_period = generators_up / periods_per_equave
	local gens_down_per_period = generators_down / periods_per_equave
	
	-- Get and simplify the step ratio
	local kp = step_ratio[1]
	local kq = step_ratio[2]
	local k = utils._gcd(kp, kq)
	local num = kp / k
	local den = kq / k
	
	-- How many large and small steps per period?
	local x = input_mos.nL / periods_per_equave		-- Large step count
	local y = input_mos.ns / periods_per_equave		-- Small step count
	
	-- How many esteps are in the equave? Gamut does not include any notes reached by
	-- increments smaller than a chroma, so if the step ratio is not simplified, the
	-- gamut returned will be for a simplified step ratio
	local esteps_per_equave = input_mos.nL * num + input_mos.ns * den
	
	-- Similarly, how many esteps per period?
	local esteps_per_period = x * num + y * den
	
	-- How long should the genchain extend after the initial genchain?
	-- For a basic step ratio 2:1, extend by x
	-- For a collapsed or equalized step ratio, don't extend at all
	-- For any other ratio p:q (simplified), do this calculation:
	-- x*floor(p/2) + y*floor(q/2)
	-- This is such that each altered note (what would be the black keys on a piano)
	-- has names that contain the fewest chromas possible, even if they have more than
	-- one name. EG, standard notation has C#/Db have two names, but both names
	-- have the fewest possible accidentals
	local genchain_extend = 0
	if num / den == 2 then
		genchain_extend = x
	elseif num == den or den == 0 then
		genchain_extend = 0
	else
		genchain_extend = x * math.floor(num/2) + y * math.floor(den/2)
	end
	
	-- How long are the genchains? Length is per period
	-- Genchain length counts the root, hence the +1
	local ascending_genchain_length = gens_up_per_period + genchain_extend + 1
	local descending_genchain_length = gens_down_per_period + genchain_extend + 1
	
	-- Get the ascending and descending genchains
	-- The genchains are notationally agnostic so notation needs to be applied to them
	local ascending_genchain = mosnot.mos_nomacc_chain(input_mos, gens_up_per_period, ascending_genchain_length, true)
	local descending_genchain = mosnot.mos_nomacc_chain(input_mos, gens_down_per_period, descending_genchain_length, false)
	
	-- Create an empty gamut
	local gamut = {}
	for i = 1, esteps_per_equave + 1 do
		table.insert(gamut, "")
	end
	
	-- How many esteps are the bright and dark generators?
	local bright_gen = mos.bright_gen(input_mos)
	local esteps_per_bright_gen = bright_gen['L'] * num + bright_gen['s'] * den
	local esteps_per_dark_gen = esteps_per_period - esteps_per_bright_gen
	
	-- Add the notes to the gamut
	for j = 1, periods_per_equave do
		local bright_accumulator = 0
		for i = 1, #ascending_genchain[j] do
			local index = (bright_accumulator % esteps_per_period) + (j - 1) * esteps_per_period + 1
			
			-- Convert the notationally agnostic form into a form that uses given notation
			local note = ascending_genchain[j][i]
			local note_symbol = string.sub(note_symbols, note['Mossteps'] + 1, note['Mossteps'] + 1)
			local chroma_count = note['Chromas']
			local note_name = note_symbol .. string.rep(chroma_plus_symbol, chroma_count)
			
			gamut[index] = gamut[index] .. note_name
			bright_accumulator = bright_accumulator + esteps_per_bright_gen
		end
		local dark_accumulator = esteps_per_dark_gen
		for i = 2, #descending_genchain[j] do
			local index = (dark_accumulator % esteps_per_period) + (j - 1) * esteps_per_period + 1
			
			-- Convert the notationally agnostic form into a form that uses given notation
			local note = descending_genchain[j][i]
			local note_symbol = string.sub(note_symbols, note['Mossteps'] + 1, note['Mossteps'] + 1)
			local chroma_count = note['Chromas'] * -1
			local note_name = note_symbol .. string.rep(chroma_minus_symbol, chroma_count)
			
			-- Add to gamut
			-- If there is a note there already, then append and separate with a slash
			if gamut[index] ~= "" then
				gamut[index] = gamut[index] .. "/" .. note_name
			else
				gamut[index] = gamut[index] .. note_name
			end
			dark_accumulator = dark_accumulator + esteps_per_dark_gen
		end
	end
	
	-- Last note in the gamut is the root up one equave
	gamut[#gamut] = gamut[1]
	
	return gamut
end

function p.mos_gamut_frame(frame)
	-- Default parameters for input mos and step ratio (5L 2s and 2:1 step ratio)
	local input_mos_unparsed = frame.args['Scale Signature']
	local input_mos = mos.parse(input_mos_unparsed) or mos.new(2, 5, 2)
	
	-- Step ratio
	local step_ratio = { 2, 1 }
	if string.len(frame.args['Step Ratio']) > 0 then
		step_ratio = mosnot.parse_step_ratio(frame.args['Step Ratio'])
	end
	
	-- Get the number of mossteps per period and equave
	local mossteps_per_equave = input_mos.nL + input_mos.ns
	local periods_per_equave = utils._gcd(input_mos.nL, input_mos.ns)
	local mossteps_per_period = mossteps_per_equave / periods_per_equave
	
	-- If certain params were left blank and the scalesig is 5L 2s, the default
	-- params will be for standard notation
	local scale_sig = mos.as_string(input_mos)
	
	-- The default UDP corresponds to the middle mode. For mosses with an even
	-- number of modes, there are two middle modes, so use the brighter of the
	-- two instead.
	-- If it's 5L 2s, default to the second-brightest mode.
	local udp = { periods_per_equave * math.ceil((mossteps_per_period - 1)/ 2), periods_per_equave * math.floor((mossteps_per_period - 1) / 2) }
	if scale_sig == "5L 2s" then
		udp = { 5, 1 }
	end
	if string.len(frame.args['UDP']) > 0 then
		udp = mosnot.parse_udp(frame.args['UDP'])
	end
	local generators_up = udp[1]
	local generators_down = udp[2]
	
	-- Get notation: naturals (or nominals), sharp symbol, and flat symbol
	local notation_default = { ['Naturals'] = string.sub("JKLMNOPQRSTUVWXYZ", 1, mossteps_per_equave), ['Sharp'] = "&", ['Flat'] = "@" }
	if scale_sig == "5L 2s" then
		notation_default['Naturals'] = "CDEFGAB"
		notation_default['Sharp'] = "#"
		notation_default['Flat'] = "b"
	end
	local notation = mosnot.parse_notation(frame.args['Notation']) or notation_default
	local note_symbols = notation['Naturals']
	local chroma_plus_symbol = notation['Sharp']
	local chroma_minus_symbol = notation['Flat']
	
	-- Get the gamut
	local gamut = p.mos_gamut(input_mos, udp, step_ratio, note_symbols, chroma_plus_symbol, chroma_minus_symbol)

	-- Since the gamut on a mos page is just text, so will this
	-- Formatting options may be explored at a later date
	local result = ""
	for i = 1, #gamut - 1 do
		-- If the note name does not contain accidentals, it's a natural and should be bold
		local note_name = gamut[i]
		if string.match(note_name, chroma_plus_symbol) or string.match(note_name, chroma_minus_symbol) then
			result = result .. note_name .. ", "
		else
			result = result .. "'''" .. note_name .. "''', "
		end
	end
	result = result .. "'''" .. gamut[#gamut] .. "'''"
	
	return result
end

return p