Module:MOS notation

From Xenharmonic Wiki
Revision as of 19:19, 14 October 2023 by Ganaram inukshuk (talk | contribs) (Quickly added support for abbreviations for mosstep-quality-to-degree function; default is no abbreviations to avoid breaking existing templates)
Jump to navigation Jump to search
Module documentation[view] [edit] [history] [purge]
This module primarily serves as a library for other modules and has no corresponding template.


Introspection summary for Module:MOS notation 
Functions provided (8)
Line Function Params
18 parse_notation (unparsed)
35 parse_step_ratio (unparsed)
51 simplify_step_ratio (step_ratio_unsimplified)
64 parse_udp (step_ratio_unparsed)
82 mosstep_and_chroma_to_note_name (mossteps, chromas, note_symbol, chroma_symbol)
102 mos_nomacc_chain (input_mos, genchain_init_per_period, genchain_length_per_period, going_up)
197 mosstep_and_quality_to_degree (mossteps, quality, prefix, notation, wording)
294 mos_degree_chain (input_mos, genchain_length_per_period, going_up)
Lua modules required (4)
Variable Module Functions used
mos Module:MOS bright_gen
ord Module:Ordinal _ordinal
rat Module:Rational dependency not used
utils Module:Utils _gcd

No function descriptions were provided. The Lua code may have further information.


-- This is a helper module that contains commonly used functions for:
-- - MOS degrees
-- - MOS gamut
local mos = require('Module:MOS')
local rat = require('Module:Rational')
local ord = require('Module:Ordinal')
local utils = require('Module:Utils')
local p = {}

-- Helper function for parsing notation entered as a string; for example,
-- "CDEFGAB; #; b" becomes an associative array, where:
-- - the key ['Naturals'] has the value "CDEFGAB"; also called "nominals"
-- - the key ['Sharp'] has the value "#"
-- - the key ['Flat'] has the value "b"
-- The string entered is semicolon-separated
-- TODO (low-priority):
-- - Add specific symbols for double-accidentals, namely "x" for two #'s
function p.parse_notation(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
	
	local notation = { ['Naturals'] = parsed[1], ['Sharp'] = parsed[2], ['Flat'] = parsed[3] }
	if #parsed == 3 then
		return notation
	else
		return nil
	end
end

-- Helper function for parsing a step ratio entered as a string "p/q"
function p.parse_step_ratio(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
	
	if #parsed == 2 then
		return { tonumber(parsed[1]), tonumber(parsed[2]) }
	else
		return nil
	end
end

-- Helper function to simplify step ratio
function p.simplify_step_ratio(step_ratio_unsimplified)
	
	local kp = step_ratio_unsimplified[1]
	local kq = step_ratio_unsimplified[2]
	local k = utils._gcd(kp, kq)
	local num = kp / k
	local den = kq / k
	
	return { num, den }
end

-- Helper function for parsing a UDP entered as a string "up,dp"
-- To avoid potential issues, the "," character is used instead of "|"
function p.parse_udp(step_ratio_unparsed)
	
	local parsed = {}
	for entry in string.gmatch(step_ratio_unparsed, '([^,]+)') do
		local trimmed = entry:gsub("^%s*(.-)%s*$", "%1")
		table.insert(parsed, trimmed)		-- Add to array
	end
	
	if #parsed == 2 then
		return { tonumber(parsed[1]), tonumber(parsed[2]) }
	else
		return nil
	end
end

-- Helper function that converts a note name given as a quantity of mossteps
-- and chromas (see gamut function) into a name, such as "C#"
-- To be used in conjunction with the genchain function
function p.mosstep_and_chroma_to_note_name(mossteps, chromas, note_symbol, chroma_symbol)
	
	local note_name = note_symbol .. string.rep(chroma_symbol, math.abs(chromas))
	return note_name
end

-- Helper function for creating a genchain, or specifically, a nominal-accidental chain.
-- This can only work in one direction at a time, so it's necessary to call this twice,
-- once for each direction (going up by the bright generator, or down). One genchain
-- is generated for each period, so this returns an array of arrays.
-- This genchain is agnostic of notation, and only denotes the mossteps needed to reach
-- a note, followed by the number of chromas. For example, F# is reached going up 3
-- mossteps and adding one chroma; Fb is the same except subtracting one chroma.
-- Specific notation is needed to interpret this into note names.
-- Parameters:
-- - input_mos - the mos itself represented as a data structure from Module:MOS
-- - genchain_init_per_period - how many named pitches per period are there without accidentals added?
--   This is either the value u or d for the UDP of up|dp.
-- - genchain_length_per_period - how many generators should the genchain extend after the root?
-- - going_up - bool; whether the genchain is going up or down; true for up, false for down
function p.mos_nomacc_chain(input_mos, genchain_init_per_period, genchain_length_per_period, going_up)
	-- Default parameters for testing
	--[[
	local input_mos = input_mos or mos.new(5, 2, 2)
	local genchain_init_per_period = genchain_init_per_period or 5
	local genchain_length_per_period = genchain_length_per_period or 10
	local note_symbols = note_symbols or "CDEFGAB"
	local chroma_symbol = chroma_symbol or "#"
	local going_up = going_up or true
	]]--
	
	-- 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
	
	--[[
	-- Split the note symbols string into subsets
	-- This is only necessary if the mos is multi-period
	local note_subsets = {}
	for i = 1, periods_per_equave do
		local start_index = (i - 1) * mossteps_per_period + 1
		local stop_index = i * mossteps_per_period
		local substr = string.sub(note_symbols, start_index, stop_index)
		table.insert(note_subsets, substr)
	end
	]]--
	
	-- Create the genchain for each period
	local genchains = {}
	for i = 1, periods_per_equave do
		--local note_names = note_subsets[i]
		
		-- Get the size of the generator in mossteps
		local gen = mos.bright_gen(input_mos)
		local gen_in_mossteps = gen['L'] + gen['s']
		
		-- If the genchain is descending (ie, going_up is false), switch to
		-- using the dark gen in mossteps, which is the period complement
		-- of the bright gen; going up by the dark gen is the same as going
		-- down by the bright gen
		if not going_up then
			gen_in_mossteps = mossteps_per_period - gen_in_mossteps
		end
		
		-- Use this value, with modular arithmteic, as an index to get the note name
		local accumulator = 0
		
		-- Create a genchain that initially starts at the root
		--local root = string.sub(note_names, 1, 1)
		--local genchain = { root }
		local root_offest = (i - 1) * mossteps_per_period		-- To make sure that, across all periods, every note has a unique index
		local root = { ['mossteps'] = root_offest, ['chromas'] = 0 }
		local genchain = { root }
		
		-- Create the rest of the genchain
		for j = 1, genchain_length_per_period do
			-- Increment the index by the generator
			accumulator = accumulator + gen_in_mossteps
			
			-- Convert the accumulator into an index
			local index = accumulator % mossteps_per_period
			
			-- Add accidentals
			-- This is negative if the genchain is descending
			local accidentals_to_add = 0
			if j > genchain_init_per_period then
				accidentals_to_add = math.ceil((j - genchain_init_per_period) / mossteps_per_period)
			end
			if not going_up then
				accidentals_to_add = accidentals_to_add * -1
			end
			
			-- Get the final note name
			local note_name = {}
			note_name['mossteps'] = index + root_offest	-- Mossteps needed to reach a note
			note_name['chromas'] = accidentals_to_add	-- How many chromas
			
			-- Add the note name
			table.insert(genchain, note_name)
		end
		
		-- Add the genchain
		table.insert(genchains, genchain)
	end
	
	return genchains
end

-- Helper function that converts a scale degree given as a quantity of mossteps
-- and a numeric quality (0=perf, 1=maj, -1=min, 2=aug, -2=dim, etc) into a
-- scale degree
-- To be used in conjunction with the degrees function
-- For notation: options include mosstep, mosdegree, and ordinal (not recommended except for maybe 5L 2s)
-- For wording: options include abbreviated or not abbreviated (type in nothing for this option)
function p.mosstep_and_quality_to_degree(mossteps, quality, prefix, notation, wording)
	
	-- Notation options currently include:
	-- - mosstep, for intervals
	-- - mosdegree, for scale degrees
	-- - ordinal, for diatonic-like numbering; can be used for either intervals
	--   or scale degrees
	local prefix = prefix or "mos"				-- Default prefix is mos
	local notation = notation or "mosdegree"	-- Default notation is mosdegree
	local wording = wording or ""				-- Default wording is no abbreviations

	local degree_name = ""
	
	if wording ~= "abbreviated" then
		if notation == "mosstep" then
			degree_name = mossteps .. "-" .. prefix .. "step"
		elseif notation == "mosdegree" then
			degree_name = mossteps .. "-" .. prefix .. "degree"
		elseif notation == "ordinal" then
			-- Add a dash between the prefix and ordinal, if a prefix is given
			if prefix == "" then
				degree_name = ord._ordinal(mossteps + 1)
			else
				degree_name = prefix .. "-" .. ord._ordinal(mossteps + 1)
			end
		end
	
		if quality == 0 then
			degree_name = "Perfect " .. degree_name
		elseif quality == 1 then
			degree_name = "Major " .. degree_name
		elseif quality == 2 then
			degree_name = "Augmented " .. degree_name
		elseif quality > 2 then
			degree_name = (quality - 1) .. "× augmented " .. degree_name
		elseif quality == -1 then
			degree_name = "Minor " .. degree_name
		elseif quality == -2 then
			degree_name = "Diminished " .. degree_name
		elseif quality < -2 then
			degree_name = (math.abs(quality) - 1) .. "× diminished " .. degree_name
		end
	else
		if notation == "mosstep" then
			degree_name = mossteps .. "-" .. prefix .. "s"
		elseif notation == "mosdegree" then
			degree_name = mossteps .. "-" .. prefix .. "d"
		elseif notation == "ordinal" then
			-- Add a dash between the prefix and ordinal, if a prefix is given
			if prefix == "" then
				degree_name = ord._ordinal(mossteps + 1)
			else
				degree_name = prefix .. "-" .. ord._ordinal(mossteps + 1)
			end
		end
	
		if quality == 0 then
			degree_name = "P" .. degree_name
		elseif quality == 1 then
			degree_name = "M" .. degree_name
		elseif quality == 2 then
			degree_name = "A" .. degree_name
		elseif quality > 2 then
			degree_name = string.rep("A", quality - 1) .. degree_name
		elseif quality == -1 then
			degree_name = "m" .. degree_name
		elseif quality == -2 then
			degree_name = "d" .. degree_name
		elseif quality < -2 then
			degree_name = string.rep("d", quality - 1) .. degree_name
		end
	end
	
	return degree_name
end

-- Function that produces a chain of scale degrees. What scale degrees are
-- reached by stacking a generator?
-- (EG, major 2nd, augmented 2nd, etc)
-- This function only works one direction at a time, so it's necessary to call
-- it twice, one for each direction.
-- Quality encodes maj/min/aug/perf/dim numerically:
-- -  3 = 2x augmented
-- -  2 = 1x augmented
-- -  1 = major
-- -  0 = perfect (used for generators and root)
-- - -1 = minor
-- - -2 = 1x diminished
-- - -3 = 2x diminished
-- The following name rules are followed, and numeric values described above are used:
-- - If an interval is the period (including the unison and equave), then the quality is perfect.
-- - If an interval class is the bright gen, then the qualities are perfect (large size) and diminished (small size).
-- - If an interval class is the dark gen, then the qualities are augmented (large size) and perfect (small size).
-- - For all other intervals and for generators of an nL ns mos, then the qualities are major (large size) and minor (small size).
-- - Alterations indicate raising the large size or lowering the small size by a chroma, and are also denoted numerically.
-- - Intervals whose large and small sizes feature already-augmented/diminished intervals will have the proper values to denote 2x-augmented and 2x-diminished intervals.
--   For example, a dark gen has a large size of augmented, so altering it by adding a chroma makes it 2x-augmented.
function p.mos_degree_chain(input_mos, genchain_length_per_period, going_up)
	-- Default parameters for testing
	--[[
	local input_mos = input_mos or mos.new(5, 2, 2)
	local genchain_length_per_period = genchain_length_per_period or 10
	local going_up = false
	]]--
	
	-- 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
	
	-- Get the number of mossteps for the generators
	local bright_gen = mos.bright_gen(input_mos)
	local mossteps_per_bright_gen = bright_gen['L'] + bright_gen['s']
	local mossteps_per_dark_gen = mossteps_per_period - mossteps_per_bright_gen
	
	local degreechain = {}
	for j = 1, periods_per_equave do
		local chain_for_period = {}

		for i = 1, genchain_length_per_period do
			
			-- Calculate mossteps
			local mossteps = 0
			if going_up then
				mossteps = (i - 1) * mossteps_per_bright_gen % mossteps_per_period + (j - 1) * mossteps_per_period
			else
				mossteps = (i - 1) * mossteps_per_dark_gen % mossteps_per_period + (j - 1) * mossteps_per_period
			end

			-- Calculate quality
			-- The first two elements in the chain are always perfect
			-- All intervals after that are major (or minor if going down)
			-- After the major intervals are augmented intervals, which starts
			-- with the augmented dark generator, which comes before the
			-- augmented unison. (or minor and dim bright gen if going down)
			-- For nL ns mosses, generators are major and minor instead, so only
			-- the root is perfect
			local quality = 0
			if input_mos.nL ~= input_mos.ns then
				if i == 1 or i == 2 then
					quality = 0
				else
					-- Offsetting i by +1 will make it so the dark generator
					-- before the augmented unison is denoted as augmented,
					-- but lua's start-from-1 indexing offsets it by 1 already.
					quality = math.floor(i / mossteps_per_period) + 1
					if not going_up then
						quality = quality * -1
					end
				end
			else
				if i == 1 then
					quality = 0
				else
					quality = math.floor((i + 1) / mossteps_per_period)
					if not going_up then
						quality = quality * -1
					end
				end
			end
			
			-- Put together the name
			local degree = { ['mossteps'] = mossteps, ['quality'] = quality }
			table.insert(chain_for_period, degree)
		end
		table.insert(degreechain, chain_for_period)
	end
	
	return degreechain
end

return p