Module:MOS

Revision as of 21:46, 1 June 2024 by Ganaram inukshuk (talk | contribs) (a bugfix; reduce functions now accept negative intervals)
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 (47)
Line Function Params
44 new (nL, ns, equave)
52 parse (unparsed)
70 as_string (mos)
80 as_long_string (mos)
90 interval_as_string (interval)
122 brightest_mode (mos)
146 darkest_mode (mos)
176 bright_gen (mos)
209 dark_gen (mos)
216 period (mos)
228 equave (mos)
244 unison none
254 chroma none
263 large_step none
272 small_step none
281 augmented_step none
286 diminished_step none
306 interval (mos, step_count, size_offset)
326 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)
393 interval_step_count (interval)
407 interval_chroma_count (interval, mos, size_offset)
420 interval_add (interval_1, interval_2)
429 interval_sub (interval_1, interval_2)
438 interval_mul (interval, amt)
451 period_complement (interval, mos)
457 equave_complement (interval, mos)
464 period_reduce (interval, mos)
475 equave_reduce (interval)
492 mos_to_et (mos, step_ratio)
499 bright_gen_to_et_steps (mos, step_ratio)
505 dark_gen_to_et_steps (mos, step_ratio)
510 period_to_et_steps (mos, step_ratio)
515 equave_to_et_steps (mos, step_ratio)
521 interval_to_et_steps (interval, step_ratio)
531 bright_gen_to_cents (mos, step_ratio)
539 dark_gen_to_cents (mos, step_ratio)
546 period_to_cents (mos, step_ratio)
553 equave_to_cents (mos, step_ratio)
561 interval_to_cents (interval, mos, step_ratio)
573 find_ancestor (mos, target_note_count)
600 tester none
Lua modules required (3)
Variable Module Functions used
et Module:ET new
rat Module:Rational parse
eq
as_ratio
cents
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
-- - Producing vectors for simple mos intervals
-- - Interval arithmetic, in the form of adding vectors of L's and s's, and
--   period/equave-reducing intervals
-- - Finding equal tunings for mosses
local rat = require('Module:Rational')
local utils = require('Module:Utils')
local et = require('Module:ET')
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.
-- - Functions that have to do with equal tunings will have "et" in its name.

--------------------------------------------------------------------------------
------------------------------- 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 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 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 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.
-- EG, a perfect 4-diastep (perf. 5th) is 4 steps. Since it's the large size,
-- the offset is 0, but to get the diminished 5th, the offset should be -1.
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 usually denote distances between two scale degrees and should be
-- positive values. Normalizing makes a negative interval positive again.
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. Intervals
-- can be negative, resulting in a negative output.
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.
-- If the number of chromas from a small (EG minor) interval is desired, then
-- using the param size_offset can be used: 0 for chromas from large size, -1
-- for chromas from small size. This can exceed the range [-1, 0] if needed.
-- EG, a diminished 2-diastep (dim. 3rd) has the vector {0,2}. It's reached by
-- either lowering the major 2-step by 2 chromas, or lowering the minor 2-step
-- by 1 chroma.
function p.interval_chroma_count(interval, mos, size_offset)
	local size_offset = size_offset or 0		-- Default of 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, mos)
	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. This works like
-- modular arithmetic, so passing a negative interval returns a positive one.
function p.period_reduce(interval, mos)
	local interval = p.normalize_interval(interval)
	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. This works like
-- modular arithmetic, so passing a negative interval returns a positive one.
function p.equave_reduce(interval)
	local interval = p.normalize_interval(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

--------------------------------------------------------------------------------
---------------------------- EQUAL-TUNING FUNCTIONS ----------------------------
--------------------------------------------------------------------------------

-- Given a mos and a step ratio, return an equal tuning (or equal division).
-- The step ratio is entered as a 2-element array to allow non-simplified
-- ratios to be entered. (The rational module isn't suitable since it simplifies
-- ratios.)
function p.mos_to_et(mos, step_ratio)
	local et_size = mos.nL * step_ratio[1] + mos.ns * step_ratio[2]
	return et.new(et_size, mos.equave, rat.as_ratio(mos.equave))
end

-- Given a mos and a step ratio, return the number of et-steps for its bright
-- generator.
function p.bright_gen_to_et_steps(mos, step_ratio)
	return p.interval_to_et_steps(p.bright_gen(mos), step_ratio)
end

-- Given a mos and a step ratio, return the number of et-steps for its dark
-- generator.
function p.dark_gen_to_et_steps(mos, step_ratio)
	return p.interval_to_et_steps(p.dark_gen(mos), step_ratio)
end

-- Given a mos and a step ratio, return the number of et-steps for its period.
function p.period_to_et_steps(mos, step_ratio)
	return p.interval_to_et_steps(p.period(mos), step_ratio)
end

-- Given a mos and a step ratio, return the number of et-steps for its equave.
function p.equave_to_et_steps(mos, step_ratio)
	return p.interval_to_et_steps(p.equave(mos), step_ratio)
end

-- Given an interval vector and step ratio, compute the number of et-steps it
-- corresponds to.
function p.interval_to_et_steps(interval, step_ratio)
	return interval['L'] * step_ratio[1] + interval['s'] * step_ratio[2]
end

--------------------------------------------------------------------------------
------------------------------- CENT FUNCTIONS ---------------------------------
--------------------------------------------------------------------------------

-- Given a mos and a step ratio, return the number of cents for its bright
-- generator.
function p.bright_gen_to_cents(mos, step_ratio)
	local interval_steps = p.interval_to_et_steps(p.bright_gen(mos), step_ratio)
	local equave_steps = p.equave_to_et_steps(mos, step_ratio)
	return interval_steps * rat.cents(mos.equave) / equave_steps
end

-- Given a mos and a step ratio, return the number of cents for its dark
-- generator.
function p.dark_gen_to_cents(mos, step_ratio)
	local interval_steps = p.interval_to_et_steps(p.dark_gen(mos), step_ratio)
	local equave_steps = p.equave_to_et_steps(mos, step_ratio)
	return interval_steps * rat.cents(mos.equave) / equave_steps
end

-- Given a mos and a step ratio, return the number of cents for its period.
function p.period_to_cents(mos, step_ratio)
	local interval_steps = p.interval_to_et_steps(p.period(mos), step_ratio)
	local equave_steps = p.equave_to_et_steps(mos, step_ratio)
	return interval_steps * rat.cents(mos.equave) / equave_steps
end

-- Given a mos and a step ratio, return the number of cents for its equave.
function p.equave_to_cents(mos, step_ratio)
	local interval_steps = p.interval_to_et_steps(p.equave(mos), step_ratio)
	local equave_steps = p.equave_to_et_steps(mos, step_ratio)
	return interval_steps * rat.cents(mos.equave) / equave_steps
end

-- Given an interval vector and step ratio, compute the number of cents it
-- corresponds to.
function p.interval_to_cents(interval, mos, step_ratio)
	local interval_steps = p.interval_to_et_steps(interval, step_ratio)
	local equave_steps = p.equave_to_et_steps(mos, step_ratio)
	return interval_steps * rat.cents(mos.equave) / equave_steps
end

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

-- 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)
	return p.equave_complement({['L']=-3,['s']=-1},p.new(5,2))

end

return p