Module:MOS

From Xenharmonic Wiki
Revision as of 06:02, 1 June 2024 by Ganaram inukshuk (talk | contribs) (Bugfixes, added size-offset param to chroma-count function; added normalize function that turns negative intervals back into positive intervals (since intervals here are meant to be interpreted as distances between two scale degrees))
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.

This module provides functions for working with MOS scales in Lua code.


Introspection summary for Module:MOS 
Functions provided (36)
Line Function Params
41 new (nL, ns, equave)
49 parse (unparsed)
67 as_string (mos)
77 as_long_string (mos)
87 interval_as_string (interval)
119 brightest_mode (mos)
143 darkest_mode (mos)
173 bright_gen (mos)
206 dark_gen (mos)
213 period (mos)
225 equave (mos)
241 unison none
250 large_step none
259 small_step none
269 chroma none
278 augmented_step none
283 diminished_step none
301 interval (mos, step_count, size_offset)
321 interval_from_step_sequence (step_sequence)
356 normalize_interval (interval)
365 bright_gen_step_count (mos)
371 dark_gen_step_count (mos)
376 period_step_count (mos)
381 equave_step_count (mos)
386 period_count (mos)
392 interval_step_count (interval)
404 interval_chroma_count (interval, mos, size_offset)
417 interval_add (interval_1, interval_2)
426 interval_sub (interval_1, interval_2)
435 interval_mul (interval, amt)
448 period_complement (interval, mos)
454 equave_complement (interval)
460 period_reduce (interval, mos)
469 equave_reduce (interval)
501 find_ancestor (mos, target_note_count)
528 tester none
Lua modules required (3)
Variable Module Functions used
et Module:ET dependency not used
rat Module:Rational parse
eq
as_ratio
utils Module:Utils _gcd

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


-- Module for working with mosses in lua code; this serves as a "library" for
-- mos-related modules and thus does not have a corresponding template.
-- Functionality includes:
-- - Creating/parsing mosses
-- - Creating scalesigs (string representations) of mosses
-- - Finding certain modes of a mos
-- - Finding generators for a mos
-- - Interval arithmetic, in the form of adding vectors of L's and s's, and
--   period/equave-reducing intervals
local rat = require('Module:Rational')
local utils = require('Module:Utils')
local et = require('Module:ET')		-- Used for unused function
local p = {}

-- Naming scheme for function names:
-- - Functions related to mosses don't have any special names.
-- - Functions related to a mos's modes generally end with "mode".
-- - Functions related to a mos's generators, equave, or period contain the
--   corresponding interval as part of its name.
-- - Functions related to intervals generally begin with "interval".
-- - Interval complement/reduce functions end with "complement" and "reduce".
-- - Functions that produce strings generally have the phrase "as string".
-- - Functions that "count" something generally end with "count".
-- - If a function requires an interval and mos as input, the interval(s) come
--   after the mos.

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

function round(num, numDecimalPlaces)
  local mult = 10^(numDecimalPlaces or 0)
  return math.floor(num * mult + 0.5) / mult
end

--------------------------------------------------------------------------------
-------------------------------- BASE FUNCTIONS --------------------------------
--------------------------------------------------------------------------------

-- Create a new mos. (Contains the number of large and small steps, and equave.)
function p.new(nL, ns, equave)
	local nL = nL or 5
	local ns = ns or 2
	local equave = equave or 2
	return { nL = nL, ns = ns, equave = equave }
end

-- Pasre a mos from its scalesig.
function p.parse(unparsed)
	local nL, ns, equave = unparsed:match('^(%d+)[Ll]%s*(%d+)[Ss]%s*(.*)$')
	nL = tonumber(nL)
	ns = tonumber(ns)
	equave = equave:match('^%((.*)-equivalent%)$') or equave:match('^⟨(.*)⟩$') or equave:match('^<(.*)>$') or '2/1' -- Assumes this is a rational ratio written a/b
	equave = rat.parse(equave)
	if nL == nil or ns == nil or equave == nil then
		return nil
	end
	return p.new(nL, ns, equave)
end

--------------------------------------------------------------------------------
------------------------------- STRING FUNCTIONS -------------------------------
--------------------------------------------------------------------------------

-- Construct a string representation (scalesig) for a MOS structure.
-- Scalesig is "xL ys", or "xL ys<p/q>" for nonoctave scales.
function p.as_string(mos)
	local suffix = ''
	if not rat.eq(mos.equave, 2) then
		suffix = '⟨' .. rat.as_ratio(mos.equave):lower() .. '⟩'
	end
	return '' .. mos.nL .. 'L ' .. mos.ns .. 's' .. suffix
end

-- Construct a longer string representation for a MOS structure.
-- Scalesig is "xL ys", or "xL ys (p/q-equivalent)" for nonoctave scales.
function p.as_long_string(mos)
	local suffix = ''
	if not rat.eq(mos.equave, 2) then
		suffix = string.format(" (%s-equivalent)", rat.as_ratio(mos.equave):lower())
	end
	return '' .. mos.nL .. 'L ' .. mos.ns .. 's' .. suffix
end

-- Given an interval as a vector of L's and s's, produce a string "iL + js",
-- where i and j are the quantities for L and s.
function p.interval_as_string(interval)
	local L_string = ""
	local s_string = ""
	if interval['L'] == 1 then
		L_string = "L"
	elseif interval['L'] ~= 0 and interval['L'] ~= 1 then
		L_string = string.format("%dL", interval['L'])
	end
	if interval['s'] == 1 then
		s_string = "s"
	elseif interval['s'] ~= 0 and interval['s'] ~= 1 then
		s_string = string.format("%ds", interval['s'])
	end
	
	if L_string == "" and s_string == "" then
		return "0"
	elseif L_string ~= "" and s_string == "" then 
		return L_string
	elseif L_string == "" and s_string ~= "" then 
		return s_string
	else
		return L_string .. " + " .. s_string
	end
end

--------------------------------------------------------------------------------
------------------------------- MODE FUNCTIONS ---------------------------------
--------------------------------------------------------------------------------

-- Find the brightest (true-mos) mode of a mos.
-- Calculation is based on the definition of a Christoffel word, as the closest
-- integer approximation to line y = #s/#L*x.
function p.brightest_mode(mos)
	local nL = mos.nL
	local ns = mos.ns
	local d = utils._gcd(nL, ns)
	if d > 1 then -- use single period mos, with period as new equave
		nL = round(nL/d)
		ns = round(ns/d)
	end
	local current_L, current_s = 0, 0
	local result = ''
	while current_L < nL or current_s < ns do
		if (current_s + 1) * nL <= ns * (current_L) then
            current_s = current_s + 1
            result = result .. 's'
        else
            current_L = current_L + 1
            result = result .. 'L'
        end
	end
	return string.rep(result, d)
end

-- Find the darkest true-mos mode of a mos.
-- It's the reverse of the brightest mode.
function p.darkest_mode(mos)
	local nL = mos.nL
	local ns = mos.ns
	local d = utils._gcd(nL, ns)
	if d > 1 then -- use single period mos, with period as new equave
		nL = round(nL/d)
		ns = round(ns/d)
	end
	local current_L, current_s = 0, 0
	local result = ''
	while current_L < nL or current_s < ns do
		if (current_s + 1) * nL <= ns * (current_L) then
            current_s = current_s + 1
            result = 's' .. result		-- !esreveR
        else
            current_L = current_L + 1
            result = 'L' .. result		-- !esreveR
        end
	end
	return string.rep(result, d)
end

--------------------------------------------------------------------------------
--------------- INTERVAL FUNCTIONS FOR PERFECTABLE INTERVALS -------------------
------------------ (IE, GENERATORS AND PERIOD INTERVALS) -----------------------
--------------------------------------------------------------------------------

-- Compute the bright gen as a vector of L's and s's.
-- Bright gen has two sizes: perfect (large) and diminished (small). The size
-- given by this function is the large size.
function p.bright_gen(mos)
	local nL = mos.nL
	local ns = mos.ns
	local d = utils._gcd(nL, ns)
	if d > 1 then -- use single period mos, with period as new equave
		nL = round(nL/d)
		ns = round(ns/d)
	end
	local min_dist = 2; -- the distance we get will always be <= sqrt(2)
	local current_L, current_s = 0, 0
	local result = {['L'] = 0, ['s'] = 0} 
	while current_L < nL or current_s < ns do
		if (current_s + 1) * nL <= ns * (current_L) then
            current_s = current_s + 1
        else
            current_L = current_L + 1
		end
    	if current_L < nL or current_s < ns then -- check to exclude (current_L, current_s) = (nL, ns)
    		local distance_here = math.abs(nL*current_s - ns*current_L)/math.sqrt(nL^2 + ns^2)
    		if distance_here < min_dist then
    			min_dist = distance_here
    			result['L'] = current_L
    			result['s'] = current_s
    		end
    	end
	end
	return result
end

-- Compute the dark gen as a vector of L's and s's.
-- Dark gen has two sizes: augmented (large) and perfect (small). The size given
-- by this function is the small size. It's the period complement of the bright
-- gen.
function p.dark_gen(mos)
	local bright_gen = p.bright_gen(mos)
	return p.period_complement(bright_gen, mos)
end

-- Compute the period as a vector of L's and s's.
-- Period intervals only have one size: perfect.
function p.period(mos) 
	local gcd = utils._gcd(mos.nL, mos.ns)
	local result = {
		['L'] = mos.nL / gcd,
		['s'] = mos.ns / gcd
	}
	return result
end

-- Compute the equave as a vector of L's and s's.
-- Equave intervals only have one size: perfect. Equave and period intervals are
-- the same for single-period mosses.
function p.equave(mos) 
	local result = {
		['L'] = mos.nL,
		['s'] = mos.ns
	}
	return result
end

--------------------------------------------------------------------------------
------------------ INTERVAL FUNCTIONS FOR SIMPLE INTERVALS ---------------------
--------------------------------------------------------------------------------

-- Compute the unison as a vector of L's and s's.
-- The unison is denoted by moving up from the root by zero steps, and thus does
-- not need a mos as input. It's basically a zero vector.
-- The unison only has one size: perfect.
function p.unison()
	local result = {
		['L'] = 0,
		['s'] = 0
	}
	return result
end

-- Compute the vector for a single large step.
function p.large_step()
	local result = {
		['L'] = 1,
		['s'] = 0
	}
	return result
end

-- Compute the vector for a single small step.
function p.small_step()
	local result = {
		['L'] = 0,
		['s'] = 1
	}
	return result
end

-- Compute the vector for a single chroma. It's a large step minus a small step.
-- Adding or subtracting any interval by this interval changes its "size".
function p.chroma()
	local result = {
		['L'] = 1,
		['s'] = -1
	}
	return result
end

-- Compute the vector for an augmented step. It's a large step plus a chroma.
function p.augmented_step()
	return p.interval_add(p.large_step(), p.chroma())
end

-- Compute the vector for a diminished step. It's a small step minus a chroma.
function p.diminished_step()
	return p.interval_sub(p.small_step(), p.chroma())
end

--------------------------------------------------------------------------------
---------------- INTERVAL FUNCTIONS FOR ARBITRARY INTERVALS --------------------
--------------------------------------------------------------------------------

-- Compute an arbitrary mos interval as a vector of L's and s's.
-- The step_count param is the number of mossteps in the interval. EG, in 5L 2s,
-- the large 2-mosstep is "LL", so the corresponding vector has L=2, s=0.
-- Mossteps larger than the equave (eg, the minor 9th in non-xen music theory)
-- are allowed.
-- The size_offset denotes whether the interval is the large size (0) or the
-- small size (-1). This can exceed the range of [-1, 0] to represent intervals
-- raised/lowered by multiple chromas (augmented, diminished, etc).
-- Note that for period intervals (eg, the root and equave), there is only one
-- size (0 = perfect), so -1 is diminished and 1 is augmented.
function p.interval(mos, step_count, size_offset)
	local size_offset = size_offset or 0		-- Optional param; defaults to large size
	local step_sequence = p.brightest_mode(mos)
	step_sequence = string.rep(step_sequence, math.ceil(step_count/(mos.nL + mos.ns)))
	step_sequence = string.sub(step_sequence, 1, step_count)
	
	local interval_vector = p.interval_from_step_sequence(step_sequence)
	interval_vector['L'] = interval_vector['L'] + size_offset
	interval_vector['s'] = interval_vector['s'] - size_offset
	return interval_vector
end

-- Compute an arbitrary mos interval (as a string of steps) as a vector of L's
-- and s's. This also serves as a helper function for p.interval().
-- Sequences of steps can be entered, where each step is one of five sizes:
-- - L: large step.
-- - s: small step.
-- - c: a chroma; the difference between a large and small step.
-- - A: an augmented step; a large step plus a chroma.
-- - d: a diminished step, or diesis; a small step minus a chroma.
function p.interval_from_step_sequence(step_sequence)
	local mossteps = #step_sequence
	local interval_vector = {
		['L'] = 0,
		['s'] = 0
	}
	
	for i = 1, mossteps do
		local step = string.sub(step_sequence, i, i)
		if step == "L" then
			interval_vector['L'] = interval_vector['L'] + 1
		elseif step == "s" or step == "S" then
			interval_vector['s'] = interval_vector['s'] + 1
		elseif step == "c" then
			interval_vector['L'] = interval_vector['L'] + 1
			interval_vector['s'] = interval_vector['s'] - 1
		elseif step == "A" then
			interval_vector['L'] = interval_vector['L'] + 2
			interval_vector['s'] = interval_vector['s'] - 1
		elseif step == "d" then
			interval_vector['L'] = interval_vector['L'] - 1
			interval_vector['s'] = interval_vector['s'] + 2
		end
	end
	
	return interval_vector
end

-- Intervals always denote positive values as they are distances between two
-- scale degrees, even if in the interval vector, one value is negative.
-- In cases where a negative interval is encountered or was previously needed,
-- such as if negative intervals denote direction (downwards stacking instead of
-- upwards stacking), normalize_interavl() makes those intervals positive again.
-- EG, a perfect 4-diastep (perfect 5th) is the vector { 3, 1 }, but so is
-- { -3, -1 }.
function p.normalize_interval(interval)
	return p.interval_step_count(interval) < 0 and p.interval_mul(interval, -1) or interval
end

--------------------------------------------------------------------------------
------------------------------- COUNT FUNCTIONS --------------------------------
--------------------------------------------------------------------------------

-- Given a mos, compute the number of steps in its bright gen (L's plus s's).
function p.bright_gen_step_count(mos)
	local interval = p.bright_gen(mos)
	return interval['L'] + interval['s']
end

-- Given a mos, compute the number of steps in its dark gen (L's plus s's).
function p.dark_gen_step_count(mos)
	return p.period_step_count(mos) - p.bright_gen_step_count(mos)
end

-- Given a mos, compute the number of steps in its period (L's plus s's).
function p.period_step_count(mos)
	return (mos.nL + mos.ns) / utils._gcd(mos.nL, mos.ns)
end

-- Given a mos, compute the number of steps in its equave (L's plus s's).
function p.equave_step_count(mos)
	return mos.nL + mos.ns
end

-- Given a mos, compute the number of periods it has.
function p.period_count(mos)
	return utils._gcd(mos.nL, mos.ns)
end

-- Given a vector representing an interval, compute the number of mossteps it
-- corresponds to. Knowledge of the corresponding mos is not needed.
function p.interval_step_count(interval)
	return interval['L'] + interval['s']
end

-- Given a vector representing an interval, compute the number of chromas it was
-- raised or lowered by from its large size (for non-period intervals) or its
-- perfect size (for period/root/equave intervals). This requires the mos as
-- input.
-- The offset param is used to denote whether the interval is the large size (0)
-- or the small size (-1). This way, the small size of an interval is shown as
-- having zero chromas IF the interval is to be viewed as its small size rather
-- than the large size. This can exceed the range [-1, 0] if needed.
function p.interval_chroma_count(interval, mos, size_offset)
	local size_offset = size_offset or 0
	local step_count = p.interval_step_count(interval)
	local base_interval = p.interval(mos, step_count, 0)
	
	return interval['L'] - base_interval['L'] - size_offset
end

--------------------------------------------------------------------------------
----------------------- INTERVAL ARITHMETIC FUNCTIONS --------------------------
--------------------------------------------------------------------------------

-- Add two intervals together by adding their respective vectors.
function p.interval_add(interval_1, interval_2)
	local interval_vector = { 
		['L'] = interval_1['L'] + interval_2['L'],
		['s'] = interval_1['s'] + interval_2['s']
	}
	return interval_vector
end
	
-- Subtract two intervals by subtracting their respective vectors.
function p.interval_sub(interval_1, interval_2)
	local interval_vector = { 
		['L'] = interval_1['L'] - interval_2['L'],
		['s'] = interval_1['s'] - interval_2['s']
	}
	return interval_vector
end

-- Repeatedly add the same interval to itself.
function p.interval_mul(interval, amt)
	local interval_vector = { 
		['L'] = interval['L'] * amt,
		['s'] = interval['s'] * amt
	}
	return interval_vector
end

--------------------------------------------------------------------------------
---------------------- COMPLEMENT AND REDUCE FUNCTIONS -------------------------
--------------------------------------------------------------------------------

-- Given an interval vector and a mos, find its period complement.
function p.period_complement(interval, mos)
	local period_vector = p.period(mos)
	return p.interval_sub(period_vector, interval)
end

-- Given an interval vector and a mos, find its equave complement.
function p.equave_complement(interval)
	local equave_vector = p.equave(mos, interval)
	return p.interval_sub(equave_vector, interval)
end

-- Given an interval vector and a mos, period-reduce it.
function p.period_reduce(interval, mos)
	local step_count = p.interval_step_count(interval)
	local reduce_amt = math.floor(step_count / p.period_step_count(mos))
	local periods = p.interval_mul(p.period(mos), reduce_amt)
	
	return p.interval_sub(interval, periods)
end

-- Given an interval vector and a mos, equave-reduce it.
function p.equave_reduce(interval)
	local step_count = p.interval_step_count(interval)
	local reduce_amt = math.floor(step_count / p.equave_step_count(mos))
	local equaves = p.interval_mul(p.equave(mos), reduce_amt)
	
	return p.interval_sub(interval, equaves)
end

--------------------------------------------------------------------------------
------------ UNUSED FUNCTIONS OR FUNCTIONS TO MOVE TO OTHER MODULES ------------
--------------------------------------------------------------------------------

-- Given mos a MOS structure, hardness = L/s a rational number,
-- return the et and the bright MOS generator corresponding to the hardness.
-- Currently unused
--[[
function p.et_tuning_by_hardness(mos, hardness)
	local nL, ns, equave = mos.nL, mos.ns, mos.equave
	hardness = rat.parse(hardness) or rat.new(hardness) or hardness
	if nL == nil or ns == nil or equave == nil or hardness == nil then
		return nil
	end
	L_in_et_steps, s_in_et_steps = rat.as_pair(hardness)
	local et = et.new(nL*L_in_et_steps + ns*s_in_et_steps, equave)
	local gen = p.bright_gen(mos)
	local gen_steps = gen['L']*L_in_et_steps + gen['s']*s_in_et_steps
	return et, gen_steps
end
]]--

-- Given a mos, find the ancestor mos with a target note count (default 10)
-- or less; to be moved to tamnams module
function p.find_ancestor(mos, target_note_count)
	local mos = mos or p.new(5, 2)
	local target_note_count = target_note_count or 10
	
	local z = mos.nL
	local w = 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 p.new(z, w, mos.equave)
end

--------------------------------------------------------------------------------
----------------------------------- TESTER -------------------------------------
--------------------------------------------------------------------------------

-- Tester function
function p.tester()

	local interval = p.dark_gen(p.new(5,2))
	return p.interval_chroma_count(interval, p.new(5,2), -1)
end

return p