Source code for music21.scale.scala

# ------------------------------------------------------------------------------
# Name:         scala/__init__.py
# Purpose:      Interface and representation of Scala scale files
#
# Authors:      Christopher Ariza
#
# Copyright:    Copyright © 2010-22 Michael Scott Asato Cuthbert
# License:      BSD, see license.txt
# ------------------------------------------------------------------------------
# noinspection SpellCheckingInspection
'''
This module defines classes for representing Scala scale data,
including Scala pitch representations, storage, and files.

The Scala format is defined at the following URL:
https://www.huygens-fokker.org/scala/scl_format.html

We thank Manuel Op de Coul for allowing us to include
the repository (as of May 11, 2011) with music21

Scala files are encoded as latin-1 (ISO-8859) text

Utility functions are also provided to search and find
scales in the Scala scale archive. File names can be found
with the :func:`~music21.scala.search` function.

To create a :class:`~music21.scale.ScalaScale` instance, simply
provide a root pitch and the name of the scale. Scale names are given as
the scala .scl filename.

>>> mbiraScales = scale.scala.search('mbira')
>>> mbiraScales
['mbira_banda.scl', 'mbira_banda2.scl', 'mbira_gondo.scl', 'mbira_kunaka.scl',
 'mbira_kunaka2.scl', 'mbira_mude.scl', 'mbira_mujuru.scl', 'mbira_zimb.scl']

For most people you'll want to do something like this:

>>> sc = scale.ScalaScale('a4', 'mbira_banda.scl')
>>> [str(p) for p in sc.pitches]
['A4', 'B4(-15c)', 'C#5(-11c)', 'E-5(-7c)', 'E~5(+6c)', 'F#5(+14c)', 'G~5(+1c)', 'B-5(+2c)']
'''
from __future__ import annotations

__all__ = [
    'SCALA_PATHS',
    'ScalaData',
    'ScalaFile',
    'ScalaPitch',
    'getPaths',
    'parse',
    'scl',
    'search',
]

import io
import math
import os
import pathlib
import unittest


from music21 import common
from music21.common.types import DocOrder
from music21 import environment
from music21 import interval

# scl is the library of scala files
from music21.scale.scala import scl

environLocal = environment.Environment('scale.scala')


# ------------------------------------------------------------------------------
# global variable to cache the paths returned from getPaths()
SCALA_PATHS: dict[str, dict[str, list[str]]|None] = {'allPaths': None}

[docs] def getPaths(): ''' Get all scala scale paths. This is called once or the module and cached as SCALA_PATHS, which should be used instead of calls to this function. >>> a = scale.scala.getPaths() >>> len(a) >= 3800 True ''' if SCALA_PATHS['allPaths'] is not None: return SCALA_PATHS['allPaths'] moduleName = scl if not hasattr(moduleName, '__path__'): # when importing a package name (a directory) the moduleName # may be a list of all paths contained within the package # this seems to be dependent on the context of the call: # from the command line is different from calling from the interpreter dirListing = moduleName else: # returns a list with one or more paths # the first is the path to the directory that contains xml files directory = moduleName.__path__[0] dirListing = [os.path.join(directory, x) for x in sorted(os.listdir(directory))] paths = {} # return a dictionary with keys and list of alternate names for fp in dirListing: if fp.endswith('.scl'): paths[fp] = [] # store alternative name representations # store version with no extension directory, fn = os.path.split(fp) fn = fn.replace('.scl', '') paths[fp].append(fn) # store version with removed underscores directory, fn = os.path.split(fp) fn = fn.lower() fn = fn.replace('.scl', '') fn = fn.replace('_', '') fn = fn.replace('-', '') paths[fp].append(fn) SCALA_PATHS['allPaths'] = paths return paths
# ------------------------------------------------------------------------------
[docs] class ScalaPitch: ''' Representation of a scala pitch notation >>> sp = scale.scala.ScalaPitch(' 1066.667 cents') >>> print(sp.parse()) 1066.667 >>> sp = scale.scala.ScalaPitch(' 2/1') >>> sp.parse() 1200.0 >>> sp.parse('100.0 C#') 100.0 >>> [sp.parse(x) for x in ['89/84', '55/49', '44/37', '63/50', '4/3', '99/70', '442/295', ... '27/17', '37/22', '98/55', '15/8', '2/1']] [100.0992..., 199.9798..., 299.9739..., 400.10848..., 498.04499..., 600.0883..., 699.9976..., 800.9095..., 900.0260..., 1000.0201..., 1088.2687..., 1200.0] ''' # pitch values; if it has a period, it is cents. Otherwise, it is a ratio # above the implied base ratio # integer values w/ no period or slash: 2 is 2/1 def __init__(self, sourceString=None): self.src = None if sourceString is not None: self._setSrc(sourceString) # resole all values into cents shifts self.cents = None def _setSrc(self, raw): raw = raw.strip() # get decimals and fractions raw, junk = common.getNumFromStr(raw, numbers='0123456789./') self.src = raw.strip()
[docs] def parse(self, sourceString=None): ''' Parse the source string and set self.cents. ''' if sourceString is not None: self._setSrc(sourceString) if '.' in self.src: # cents self.cents = float(self.src) else: # it is a ratio if '/' in self.src: n, d = self.src.split('/') n, d = float(n), float(d) else: n = float(self.src) d = 1.0 # http://www.sengpielaudio.com/calculator-centsratio.htm self.cents = 1200.0 * math.log((n / d), 2) return self.cents
[docs] class ScalaData: # noinspection SpellCheckingInspection ''' Object representation of data stored in a Scala scale file. This object is used to access Scala information stored in a file. To create a music21 scale with a Scala file, use :class:`~music21.scale.ScalaScale`. This is not called ScalaScale, as this name clashes with the :class:`~music21.scale.ScalaScale` that uses this object. >>> import os >>> sf = scale.scala.ScalaFile() >>> fp = common.getSourceFilePath() / 'scale' / 'scala' / 'scl' / 'tanaka.scl' >>> sf.open(fp) >>> sd = sf.read() ScaleFile descriptions are converted to unicode. >>> print(sd.description) 26-note choice system of Shohé Tanaka, Studien i.G.d. reinen Stimmung (1890) >>> sd.pitchCount 26 Distances from the tonic: >>> cat = sd.getCentsAboveTonic() >>> len(cat) 26 >>> list(int(round(x)) for x in cat[0:4]) [71, 92, 112, 182] >>> sd.pitchValues[0] <music21.scale.scala.ScalaPitch object at 0x10b16fac8> >>> sd.pitchValues[0].cents 70.6724... This will not add up with centsAboveTonic above, due to rounding >>> adj = sd.getAdjacentCents() >>> list(int(round(x)) for x in adj[0:4]) [71, 22, 20, 71] Interval Sequences >>> intSeq = sd.getIntervalSequence() >>> intSeq[0:4] [<music21.interval.Interval m2 (-29c)>, <music21.interval.Interval P1 (+22c)>, <music21.interval.Interval P1 (+20c)>, <music21.interval.Interval m2 (-29c)>] Tweak the file and be ready to write it back out: >>> sd.pitchValues[0].cents = 73.25 >>> sd.fileName = 'tanaka2.scl' >>> sd.description = 'Tweaked version of tanaka.scl' >>> fs = sd.getFileString() >>> print(fs) ! tanaka2.scl ! Tweaked version of tanaka.scl 26 ! 73.25 92.17... 111.73... 182.40... Be sure to reencode `fs` as `latin-1` before writing to disk. >>> sf.close() ''' def __init__(self, sourceString=None, fileName=None): self.src = sourceString self.fileName = fileName # store source file name # added in parsing: self.description = None # lower limit is 0, as degree 0, or the 1/1 ratio, is implied # assumes octave equivalence? self.pitchCount = None # number of lines w/ pitch values will follow self.pitchValues = []
[docs] def parse(self): ''' Parse a scala file delivered as a long string with line breaks ''' lines = self.src.split('\n') count = 0 # count non-comment lines for i, line in enumerate(lines): line = line.strip() # environLocal.printDebug(['line', line, self.fileName, i]) if line.startswith('!'): if i == 0 and self.fileName is None: # try to get from first line if '.scl' in line: # it has got the file name self.fileName = line[1:].strip() # remove leading ! continue # comment else: count += 1 if count == 1: if line != '': # may be empty self.description = line elif count == 2: if line != '': self.pitchCount = int(line) else: # remaining counts are pitches if line != '': sp = ScalaPitch(line) sp.parse() self.pitchValues.append(sp)
[docs] def getCentsAboveTonic(self): ''' Return a list of cent values above the implied tonic. ''' return [sp.cents for sp in self.pitchValues]
[docs] def getAdjacentCents(self): ''' Get cents values between adjacent intervals. ''' post = [] location = 0 for c in self.getCentsAboveTonic(): dif = c - location # environLocal.printDebug(['getAdjacentCents', 'c', # c, 'location', location, 'dif', dif]) post.append(dif) location = c # set new location return post
[docs] def setAdjacentCents(self, centList): ''' Given a list of adjacent cent values, create the necessary ScalaPitch objects and update them ''' self.pitchValues = [] location = 0 for c in centList: sp = ScalaPitch() sp.cents = location + c location = sp.cents self.pitchValues.append(sp) self.pitchCount = len(self.pitchValues)
[docs] def getIntervalSequence(self): ''' Get the scale as a list of Interval objects. ''' post = [] for c in self.getAdjacentCents(): # convert cent values to semitone values to create intervals post.append(interval.Interval(c * 0.01)) return post
[docs] def setIntervalSequence(self, iList): ''' Set the scale from a list of Interval objects. ''' self.pitchValues = [] location = 0 for i in iList: # convert cent values to semitone values to create intervals sp = ScalaPitch() sp.cents = location + i.cents location = sp.cents self.pitchValues.append(sp) self.pitchCount = len(self.pitchValues)
[docs] def getFileString(self): ''' Return a unicode-string suitable for writing a Scala file The unicode string should be encoded in Latin-1 for maximum Scala compatibility. ''' msg = [] if self.fileName is not None: msg.append(f'! {self.fileName}') # conventional to add a comment space msg.append('!') if self.description is not None: msg.append(self.description) else: # must supply empty line msg.append('') if self.pitchCount is not None: msg.append(str(self.pitchCount)) else: # must supply empty line msg.append('') # conventional to add a comment space msg.append('!') for sp in self.pitchValues: msg.append(str(sp.cents)) # add space msg.append('') return '\n'.join(msg)
# ------------------------------------------------------------------------------
[docs] class ScalaFile: ''' Interface for reading and writing scala files. On reading, returns a :class:`~music21.scala.ScalaData` object. >>> import os >>> sf = scale.scala.ScalaFile() >>> fp = common.getSourceFilePath() / 'scale' / 'scala' / 'scl' / 'tanaka.scl' >>> sf.open(fp) >>> sd = sf.read() >>> sd <music21.scale.scala.ScalaData object at 0x10b170e10> >>> sd is sf.data True >>> sf.fileName.endswith('tanaka.scl') True >>> sd.pitchCount 26 >>> sf.close() ''' def __init__(self, data=None): self.fileName = None self.file = None # store data source if provided self.data = data
[docs] def open(self, fp, mode='r'): ''' Open a file for reading ''' self.file = io.open(fp, mode, encoding='latin-1') # pylint: disable=consider-using-with self.fileName = os.path.basename(fp)
[docs] def openFileLike(self, fileLike): ''' Assign a file-like object, such as those provided by StringIO, as an open file object. ''' self.file = fileLike # already 'open'
def __repr__(self): r = '<ScalaFile>' return r
[docs] def close(self): self.file.close()
[docs] def read(self): ''' Read a file. Note that this calls readstr, which processes all tokens. If `number` is given, a work number will be extracted if possible. ''' return self.readstr(self.file.read())
[docs] def readstr(self, strSrc): ''' Read a string and process all Tokens. Returns a ABCHandler instance. ''' ss = ScalaData(strSrc, self.fileName) ss.parse() self.data = ss return ss
[docs] def write(self): ws = self.writestr() self.file.write(ws)
[docs] def writestr(self) -> str: if isinstance(self.data, ScalaData): return self.data.getFileString() return ''
# handle Scale or other objects # ------------------------------------------------------------------------------
[docs] def parse(target): # noinspection SpellCheckingInspection, PyShadowingNames ''' Get a :class:`~music21.scala.ScalaData` object from the bundled SCL archive or a file path. >>> ss = scale.scala.parse('balafon6') >>> ss.description 'Observed balafon tuning from Burma, Helmholtz/Ellis p. 518, nr.84' >>> [str(i) for i in ss.getIntervalSequence()] ['<music21.interval.Interval m2 (+14c)>', '<music21.interval.Interval M2 (+36c)>', '<music21.interval.Interval M2>', '<music21.interval.Interval m2 (+37c)>', '<music21.interval.Interval M2 (-49c)>', '<music21.interval.Interval M2 (-6c)>', '<music21.interval.Interval M2 (-36c)>'] >>> scale.scala.parse('incorrectFileName.scl') is None True >>> ss = scale.scala.parse('barbourChrom1') >>> print(ss.description) Barbour's #1 Chromatic >>> ss.fileName 'barbour_chrom1.scl' >>> ss = scale.scala.parse('blackj_gws.scl') >>> ss.description 'Detempered Blackjack in 1/4 kleismic marvel tuning' ''' match = None if isinstance(target, pathlib.Path): target = str(target) # this may be a file path to a scala file if os.path.exists(target) and target.endswith('.scl'): match = target # try from stored collections # remove any spaces target = target.replace(' ', '') if match is None: for fp in getPaths(): unused_directory, fn = os.path.split(fp) # try exact match if target.lower() == fn.lower(): match = fp break # try again, from cached reduced expressions if match is None: for fp in getPaths(): # look at alternative names for alt in getPaths()[fp]: if target.lower() == alt: match = fp break if match is None: # accept partial matches for fp in getPaths(): # look at alternative names for alt in getPaths()[fp]: if target.lower() in alt: match = fp break # might put this in a try block if match is not None: sf = ScalaFile() sf.open(match) ss = sf.read() sf.close() return ss
# ------------------------------------------------------------------------------ class TestExternal(unittest.TestCase): pass class Test(unittest.TestCase): def testScalaScaleA(self): msg = '''! slendro5_2.scl ! A slendro type pentatonic which is based on intervals of 7, no. 2 5 ! 7/6 4/3 3/2 7/4 2/1 ''' ss = ScalaData(msg) ss.parse() self.assertEqual(ss.pitchCount, 5) self.assertEqual(ss.fileName, 'slendro5_2.scl') self.assertEqual(len(ss.pitchValues), 5) self.assertEqual([f'{x.cents:.9f}' for x in ss.pitchValues], ['266.870905604', '498.044999135', '701.955000865', '968.825906469', '1200.000000000']) self.assertEqual([f'{x:.9f}' for x in ss.getCentsAboveTonic()], ['266.870905604', '498.044999135', '701.955000865', '968.825906469', '1200.000000000']) # sent values between scale degrees self.assertEqual([f'{x:.9f}' for x in ss.getAdjacentCents()], ['266.870905604', '231.174093531', '203.910001731', '266.870905604', '231.174093531']) self.assertEqual([str(x) for x in ss.getIntervalSequence()], ['<music21.interval.Interval m3 (-33c)>', '<music21.interval.Interval M2 (+31c)>', '<music21.interval.Interval M2 (+4c)>', '<music21.interval.Interval m3 (-33c)>', '<music21.interval.Interval M2 (+31c)>']) # noinspection SpellCheckingInspection def testScalaScaleB(self): msg = '''! fj-12tet.scl ! Franck Jedrzejewski continued fractions approx. of 12-tet 12 ! 89/84 55/49 44/37 63/50 4/3 99/70 442/295 27/17 37/22 98/55 15/8 2/1 ''' ss = ScalaData(msg) ss.parse() self.assertEqual(ss.pitchCount, 12) self.assertEqual(ss.fileName, 'fj-12tet.scl') self.assertEqual(ss.description, 'Franck Jedrzejewski continued fractions approx. of 12-tet') self.assertEqual([f'{x:.9f}' for x in ss.getCentsAboveTonic()], ['100.099209825', '199.979843291', '299.973903610', '400.108480470', '498.044999135', '600.088323762', '699.997698171', '800.909593096', '900.026096390', '1000.020156709', '1088.268714730', '1200.000000000']) self.assertEqual([f'{x:.9f}' for x in ss.getAdjacentCents()], ['100.099209825', '99.880633466', '99.994060319', '100.134576860', '97.936518664', '102.043324627', '99.909374409', '100.911894925', '99.116503294', '99.994060319', '88.248558022', '111.731285270']) self.assertEqual([str(x) for x in ss.getIntervalSequence()], ['<music21.interval.Interval m2 (+0c)>', '<music21.interval.Interval m2 (-0c)>', '<music21.interval.Interval m2 (-0c)>', '<music21.interval.Interval m2 (+0c)>', '<music21.interval.Interval m2 (-2c)>', '<music21.interval.Interval m2 (+2c)>', '<music21.interval.Interval m2 (-0c)>', '<music21.interval.Interval m2 (+1c)>', '<music21.interval.Interval m2 (-1c)>', '<music21.interval.Interval m2 (-0c)>', '<music21.interval.Interval m2 (-12c)>', '<music21.interval.Interval m2 (+12c)>']) # test loading a new scala object from adjacent sets ss2 = ScalaData() ss2.setAdjacentCents(ss.getAdjacentCents()) self.assertEqual([f'{x:.9f}' for x in ss2.getCentsAboveTonic()], [ '100.099209825', '199.979843291', '299.973903610', '400.108480470', '498.044999135', '600.088323762', '699.997698171', '800.909593096', '900.026096390', '1000.020156709', '1088.268714730', '1200.000000000']) def testScalaFileA(self): # noinspection SpellCheckingInspection msg = '''! arist_chromenh.scl ! Aristoxenos' Chromatic/Enharmonic, 3 + 9 + 18 parts 7 ! 50.00000 200.00000 500.00000 700.00000 750.00000 900.00000 2/1 ''' sf = ScalaFile() ss = sf.readstr(msg) self.assertEqual(ss.pitchCount, 7) # all but last will be the same # print(ss.getFileString()) self.assertEqual(ss.getFileString()[:1], msg[:1]) self.assertEqual([str(x) for x in ss.getIntervalSequence()], ['<music21.interval.Interval P1 (+50c)>', '<music21.interval.Interval m2 (+50c)>', '<music21.interval.Interval m3>', '<music21.interval.Interval M2>', '<music21.interval.Interval P1 (+50c)>', '<music21.interval.Interval m2 (+50c)>', '<music21.interval.Interval m3>']) # ------------------------------------------------------------------------------ # define presented order in documentation _DOC_ORDER: DocOrder = [ScalaData, ScalaFile, ScalaPitch, parse, search] if __name__ == '__main__': # sys.arg test options will be used in mainTest() import music21 music21.mainTest(Test)