1# -*- coding: utf-8 -*-
2# ------------------------------------------------------------------------------
3# Name:         scala/__init__.py
4# Purpose:      Interface and representation of Scala scale files
5#
6# Authors:      Christopher Ariza
7#
8# Copyright:    Copyright © 2010, 16 Michael Scott Cuthbert and the music21 Project
9# License:      BSD, see license.txt
10# ------------------------------------------------------------------------------
11
12# noinspection SpellCheckingInspection
13'''
14This module defines classes for representing Scala scale data,
15including Scala pitch representations, storage, and files.
16
17The Scala format is defined at the following URL:
18http://www.huygens-fokker.org/scala/scl_format.html
19
20We thank Manuel Op de Coul for allowing us to include
21the repository (as of May 11, 2011) with music21
22
23Scala files are encoded as latin-1 (ISO-8859) text
24
25Utility functions are also provided to search and find
26scales in the Scala scale archive. File names can be found
27with the :func:`~music21.scala.search` function.
28
29To create a :class:`~music21.scale.ScalaScale` instance, simply
30provide a root pitch and the name of the scale. Scale names are given as
31the scala .scl filename.
32
33>>> mbiraScales = scale.scala.search('mbira')
34>>> mbiraScales
35['mbira_banda.scl', 'mbira_banda2.scl', 'mbira_gondo.scl', 'mbira_kunaka.scl',
36 'mbira_kunaka2.scl', 'mbira_mude.scl', 'mbira_mujuru.scl', 'mbira_zimb.scl']
37
38For most people you'll want to do something like this:
39
40>>> sc = scale.ScalaScale('a4', 'mbira_banda.scl')
41>>> [str(p) for p in sc.pitches]
42['A4', 'B4(-15c)', 'C#5(-11c)', 'E-5(-7c)', 'E~5(+6c)', 'F#5(+14c)', 'G~5(+1c)', 'B-5(+2c)']
43
44'''
45from typing import Dict, Optional, List
46
47import io
48import math
49import os
50import pathlib
51import unittest
52
53
54from music21 import common
55from music21 import interval
56# scl is the library of scala files
57from music21.scale.scala import scl
58
59from music21 import environment
60_MOD = "scale.scala"
61environLocal = environment.Environment(_MOD)
62
63
64# ------------------------------------------------------------------------------
65# global variable to cache the paths returned from getPaths()
66SCALA_PATHS: Dict[str, Optional[Dict[str, List[str]]]] = {'allPaths': None}
67
68def getPaths():
69    '''
70    Get all scala scale paths. This is called once or the module and
71    cached as SCALA_PATHS, which should be used instead of calls to this function.
72
73    >>> a = scale.scala.getPaths()
74    >>> len(a) >= 3800
75    True
76    '''
77    if SCALA_PATHS['allPaths'] is not None:
78        return SCALA_PATHS['allPaths']
79    moduleName = scl
80    if not hasattr(moduleName, '__path__'):
81        # when importing a package name (a directory) the moduleName
82        # may be a list of all paths contained within the package
83        # this seems to be dependent on the context of the call:
84        # from the command line is different than from the interpreter
85        dirListing = moduleName
86    else:
87        # returns a list with one or more paths
88        # the first is the path to the directory that contains xml files
89        directory = moduleName.__path__[0]
90        dirListing = [os.path.join(directory, x) for x in sorted(os.listdir(directory))]
91
92    paths = {}  # return a dictionary with keys and list of alternate names
93    for fp in dirListing:
94        if fp.endswith('.scl'):
95            paths[fp] = []
96            # store alternative name representations
97            # store version with no extension
98            directory, fn = os.path.split(fp)
99            fn = fn.replace('.scl', '')
100            paths[fp].append(fn)
101            # store version with removed underscores
102            directory, fn = os.path.split(fp)
103            fn = fn.lower()
104            fn = fn.replace('.scl', '')
105            fn = fn.replace('_', '')
106            fn = fn.replace('-', '')
107            paths[fp].append(fn)
108    SCALA_PATHS['allPaths'] = paths
109    return paths
110
111
112# ------------------------------------------------------------------------------
113class ScalaPitch:
114    '''
115    Representation of a scala pitch notation
116
117    >>> sp = scale.scala.ScalaPitch(' 1066.667 cents')
118    >>> print(sp.parse())
119    1066.667
120
121    >>> sp = scale.scala.ScalaPitch(' 2/1')
122    >>> sp.parse()
123    1200.0
124    >>> sp.parse('100.0 C#')
125    100.0
126    >>> [sp.parse(x) for x in ['89/84', '55/49', '44/37', '63/50', '4/3', '99/70', '442/295',
127    ...     '27/17', '37/22', '98/55', '15/8', '2/1']]
128    [100.0992..., 199.9798..., 299.9739..., 400.10848..., 498.04499...,
129     600.0883..., 699.9976..., 800.9095..., 900.0260...,
130     1000.0201..., 1088.2687..., 1200.0]
131    '''
132    # pitch values; if has a period, is cents, otherwise a ratio
133    # above the implied base ratio
134    # integer values w/ no period or slash: 2 is 2/1
135    def __init__(self, sourceString=None):
136
137        self.src = None
138        if sourceString is not None:
139            self._setSrc(sourceString)
140
141        # resole all values into cents shifts
142        self.cents = None
143
144    def _setSrc(self, raw):
145        raw = raw.strip()
146        # get decimals and fractions
147        raw, junk = common.getNumFromStr(raw, numbers='0123456789./')
148        self.src = raw.strip()
149
150    def parse(self, sourceString=None):
151        '''
152        Parse the source string and set self.cents.
153        '''
154        if sourceString is not None:
155            self._setSrc(sourceString)
156
157        if '.' in self.src:  # cents
158            self.cents = float(self.src)
159        else:  # its a ratio
160            if '/' in self.src:
161                n, d = self.src.split('/')
162                n, d = float(n), float(d)
163            else:
164                n = float(self.src)
165                d = 1.0
166            # http://www.sengpielaudio.com/calculator-centsratio.htm
167            self.cents = 1200.0 * math.log((n / d), 2)
168        return self.cents
169
170
171
172
173class ScalaData:
174    # noinspection SpellCheckingInspection
175    '''
176    Object representation of data stored in a Scale scale file. This object is used to
177    access Scala information stored in a file. To create a music21 scale with a Scala file,
178    use :class:`~music21.scale.ScalaScale`.
179
180    This is not called ScalaScale, as this name clashes with the
181    :class:`~music21.scale.ScalaScale` that uses this object.
182
183    >>> import os
184    >>> sf = scale.scala.ScalaFile()
185    >>> fp = common.getSourceFilePath() / 'scale' / 'scala' / 'scl' / 'tanaka.scl'
186    >>> sf.open(fp)
187    >>> sd = sf.read()
188
189    >>> print(sd.description)  # converted to unicode...
190    26-note choice system of Shohé Tanaka, Studien i.G.d. reinen Stimmung (1890)
191    >>> sd.pitchCount
192    26
193
194    Distances from the tonic:
195
196    >>> cat = sd.getCentsAboveTonic()
197    >>> len(cat)
198    26
199    >>> list(int(round(x)) for x in cat[0:4])
200    [71, 92, 112, 182]
201    >>> sd.pitchValues[0]
202    <music21.scale.scala.ScalaPitch object at 0x10b16fac8>
203    >>> sd.pitchValues[0].cents
204    70.6724...
205
206    This will not add up with centsAboveTonic above, due to rounding
207
208    >>> adj = sd.getAdjacentCents()
209    >>> list(int(round(x)) for x in adj[0:4])
210    [71, 22, 20, 71]
211
212    Interval Sequences
213
214    >>> intSeq = sd.getIntervalSequence()
215    >>> intSeq[0:4]
216    [<music21.interval.Interval m2 (-29c)>,
217     <music21.interval.Interval P1 (+22c)>,
218     <music21.interval.Interval P1 (+20c)>,
219     <music21.interval.Interval m2 (-29c)>]
220
221    Tweak the file and be ready to write it back out:
222
223    >>> sd.pitchValues[0].cents = 73.25
224    >>> sd.fileName = 'tanaka2.scl'
225    >>> sd.description = 'Tweaked version of tanaka.scl'
226    >>> fs = sd.getFileString()
227    >>> print(fs)
228    ! tanaka2.scl
229    !
230    Tweaked version of tanaka.scl
231    26
232    !
233    73.25
234    92.17...
235    111.73...
236    182.40...
237
238    Be sure to reencode `fs` as `latin-1` before writing to disk.
239
240    >>> sf.close()
241    '''
242    def __init__(self, sourceString=None, fileName=None):
243        self.src = sourceString
244        self.fileName = fileName  # store source file name
245
246        # added in parsing:
247        self.description = None
248
249        # lower limit is 0, as degree 0, or the 1/1 ratio, is implied
250        # assumes octave equivalence?
251        self.pitchCount = None  # number of lines w/ pitch values will follow
252        self.pitchValues = []
253
254    def parse(self):
255        '''
256        Parse a scala file delivered as a long string with line breaks
257        '''
258        lines = self.src.split('\n')
259        count = 0  # count non-comment lines
260        for i, line in enumerate(lines):
261            line = line.strip()
262            # environLocal.printDebug(['line', line, self.fileName, i])
263            if line.startswith('!'):
264                if i == 0 and self.fileName is None:
265                    # try to get from first line
266                    if '.scl' in line:  # it has got the file name
267                        self.fileName = line[1:].strip()  # remove leading !
268                continue  # comment
269            else:
270                count += 1
271            if count == 1:
272                if line != '':  # may be empty
273                    self.description = line
274            elif count == 2:
275                if line != '':
276                    self.pitchCount = int(line)
277            else:  # remaining counts are pitches
278                if line != '':
279                    sp = ScalaPitch(line)
280                    sp.parse()
281                    self.pitchValues.append(sp)
282
283    def getCentsAboveTonic(self):
284        '''
285        Return a list of cent values above the implied tonic.
286        '''
287        return [sp.cents for sp in self.pitchValues]
288
289
290    def getAdjacentCents(self):
291        '''
292        Get cents values between adjacent intervals.
293        '''
294        post = []
295        location = 0
296        for c in self.getCentsAboveTonic():
297            dif = c - location
298            # environLocal.printDebug(['getAdjacentCents', 'c',
299            #                           c, 'location', location, 'dif', dif])
300            post.append(dif)
301            location = c  # set new location
302        return post
303
304    def setAdjacentCents(self, centList):
305        '''
306        Given a list of adjacent cent values, create the necessary ScalaPitch
307        objects and update them
308        '''
309        self.pitchValues = []
310        location = 0
311        for c in centList:
312            sp = ScalaPitch()
313            sp.cents = location + c
314            location = sp.cents
315            self.pitchValues.append(sp)
316        self.pitchCount = len(self.pitchValues)
317
318    def getIntervalSequence(self):
319        '''
320        Get the scale as a list of Interval objects.
321        '''
322        post = []
323        for c in self.getAdjacentCents():
324            # convert cent values to semitone values to create intervals
325            post.append(interval.Interval(c * 0.01))
326        return post
327
328    def setIntervalSequence(self, iList):
329        '''
330        Set the scale from a list of Interval objects.
331        '''
332        self.pitchValues = []
333        location = 0
334        for i in iList:
335            # convert cent values to semitone values to create intervals
336            sp = ScalaPitch()
337            sp.cents = location + i.cents
338            location = sp.cents
339            self.pitchValues.append(sp)
340        self.pitchCount = len(self.pitchValues)
341
342    def getFileString(self):
343        '''
344        Return a unicode-string suitable for writing a Scala file
345
346        The unicode string should be encoded in Latin-1 for maximum
347        Scala compatibility.
348        '''
349        msg = []
350        if self.fileName is not None:
351            msg.append(f'! {self.fileName}')
352        # conventional to add a comment space
353        msg.append('!')
354
355        if self.description is not None:
356            msg.append(self.description)
357        else:  # must supply empty line
358            msg.append('')
359
360        if self.pitchCount is not None:
361            msg.append(str(self.pitchCount))
362        else:  # must supply empty line
363            msg.append('')
364
365        # conventional to add a comment space
366        msg.append('!')
367        for sp in self.pitchValues:
368            msg.append(str(sp.cents))
369        # add space
370        msg.append('')
371
372        return '\n'.join(msg)
373
374
375# ------------------------------------------------------------------------------
376class ScalaFile:
377    '''
378    Interface for reading and writing scala files.
379    On reading, returns a :class:`~music21.scala.ScalaData` object.
380
381    >>> import os
382    >>> sf = scale.scala.ScalaFile()
383    >>> fp = common.getSourceFilePath() / 'scale' / 'scala' / 'scl' / 'tanaka.scl'
384    >>> sf.open(fp)
385    >>> sd = sf.read()
386    >>> sd
387    <music21.scale.scala.ScalaData object at 0x10b170e10>
388    >>> sd is sf.data
389    True
390    >>> sf.fileName.endswith('tanaka.scl')
391    True
392    >>> sd.pitchCount
393    26
394    >>> sf.close()
395    '''
396
397    def __init__(self, data=None):
398        self.fileName = None
399        self.file = None
400        # store data source if provided
401        self.data = data
402
403    def open(self, fp, mode='r'):
404        '''
405        Open a file for reading
406        '''
407        self.file = io.open(fp, mode, encoding='latin-1')  # pylint: disable=consider-using-with
408        self.fileName = os.path.basename(fp)
409
410    def openFileLike(self, fileLike):
411        '''Assign a file-like object, such as those provided by StringIO, as an open file object.
412        '''
413        self.file = fileLike  # already 'open'
414
415    def __repr__(self):
416        r = "<ScalaFile>"
417        return r
418
419    def close(self):
420        self.file.close()
421
422    def read(self):
423        '''
424        Read a file. Note that this calls readstr, which processes all tokens.
425
426        If `number` is given, a work number will be extracted if possible.
427        '''
428        return self.readstr(self.file.read())
429
430    def readstr(self, strSrc):
431        '''Read a string and process all Tokens. Returns a ABCHandler instance.
432        '''
433        ss = ScalaData(strSrc, self.fileName)
434        ss.parse()
435        self.data = ss
436        return ss
437
438    def write(self):
439        ws = self.writestr()
440        self.file.write(ws)
441
442    def writestr(self):
443        if isinstance(self.data, ScalaData):
444            return self.data.getFileString()
445        # handle Scale or other objects
446
447
448# ------------------------------------------------------------------------------
449def parse(target):
450    # noinspection SpellCheckingInspection
451    '''
452    Get a :class:`~music21.scala.ScalaData` object from
453    the bundled SCL archive or a file path.
454
455    >>> ss = scale.scala.parse('balafon6')
456    >>> ss.description
457    'Observed balafon tuning from Burma, Helmholtz/Ellis p. 518, nr.84'
458    >>> [str(i) for i in ss.getIntervalSequence()]
459    ['<music21.interval.Interval m2 (+14c)>', '<music21.interval.Interval M2 (+36c)>',
460    '<music21.interval.Interval M2>', '<music21.interval.Interval m2 (+37c)>',
461    '<music21.interval.Interval M2 (-49c)>', '<music21.interval.Interval M2 (-6c)>',
462    '<music21.interval.Interval M2 (-36c)>']
463
464    >>> scale.scala.parse('incorrectFileName.scl') is None
465    True
466
467    >>> ss = scale.scala.parse('barbourChrom1')
468    >>> print(ss.description)
469    Barbour's #1 Chromatic
470    >>> ss.fileName
471    'barbour_chrom1.scl'
472
473
474    >>> ss = scale.scala.parse('blackj_gws.scl')
475    >>> ss.description
476    'Detempered Blackjack in 1/4 kleismic marvel tuning'
477    '''
478    match = None
479
480    if isinstance(target, pathlib.Path):
481        target = str(target)
482    # this may be a file path to a scala file
483    if os.path.exists(target) and target.endswith('.scl'):
484        match = target
485
486    # try from stored collections
487    # remove any spaces
488    target = target.replace(' ', '')
489    if match is None:
490        for fp in getPaths():
491            unused_directory, fn = os.path.split(fp)
492            # try exact match
493            if target.lower() == fn.lower():
494                match = fp
495                break
496
497    # try again, from cached reduced expressions
498    if match is None:
499        for fp in getPaths():
500            # look at alternative names
501            for alt in getPaths()[fp]:
502                if target.lower() == alt:
503                    match = fp
504                    break
505    if match is None:
506        # accept partial matches
507        for fp in getPaths():
508            # look at alternative names
509            for alt in getPaths()[fp]:
510                if target.lower() in alt:
511                    match = fp
512                    break
513
514    # might put this in a try block
515    if match is not None:
516        sf = ScalaFile()
517        sf.open(match)
518        ss = sf.read()
519        sf.close()
520        return ss
521
522
523def search(target):
524    # noinspection SpellCheckingInspection
525    '''Search the scala archive for matches based on a string
526
527    >>> mbiraScales = scale.scala.search('mbira')
528    >>> mbiraScales
529    ['mbira_banda.scl', 'mbira_banda2.scl', 'mbira_gondo.scl', 'mbira_kunaka.scl',
530     'mbira_kunaka2.scl', 'mbira_mude.scl', 'mbira_mujuru.scl', 'mbira_zimb.scl']
531    '''
532    match = []
533    # try from stored collections
534    # remove any spaces
535    target = target.replace(' ', '')
536    for fp in getPaths():
537        unused_directory, fn = os.path.split(fp)
538        # try exact match
539        if target.lower() == fn.lower():
540            if fp not in match:
541                match.append(fp)
542
543    # accept partial matches
544    for fp in getPaths():
545        # look at alternative names
546        for alt in getPaths()[fp]:
547            if target.lower() in alt:
548                if fp not in match:
549                    match.append(fp)
550    names = []
551    for fp in match:
552        names.append(os.path.basename(fp))
553    names.sort()
554    return names
555
556
557
558# ------------------------------------------------------------------------------
559class TestExternal(unittest.TestCase):
560    pass
561
562
563
564class Test(unittest.TestCase):
565
566    def testScalaScaleA(self):
567        msg = '''! slendro5_2.scl
568!
569A slendro type pentatonic which is based on intervals of 7, no. 2
570 5
571!
572 7/6
573 4/3
574 3/2
575 7/4
576 2/1
577'''
578        ss = ScalaData(msg)
579        ss.parse()
580        self.assertEqual(ss.pitchCount, 5)
581        self.assertEqual(ss.fileName, 'slendro5_2.scl')
582        self.assertEqual(len(ss.pitchValues), 5)
583        self.assertEqual([f'{x.cents:.9f}' for x in ss.pitchValues],
584                         ['266.870905604', '498.044999135', '701.955000865',
585                          '968.825906469', '1200.000000000'])
586
587        self.assertEqual([f'{x:.9f}' for x in ss.getCentsAboveTonic()],
588                         ['266.870905604', '498.044999135', '701.955000865',
589                          '968.825906469', '1200.000000000'])
590        # sent values between scale degrees
591        self.assertEqual([f'{x:.9f}' for x in ss.getAdjacentCents()],
592                         ['266.870905604', '231.174093531', '203.910001731',
593                          '266.870905604', '231.174093531'])
594
595        self.assertEqual([str(x) for x in ss.getIntervalSequence()],
596                         ['<music21.interval.Interval m3 (-33c)>',
597                          '<music21.interval.Interval M2 (+31c)>',
598                          '<music21.interval.Interval M2 (+4c)>',
599                          '<music21.interval.Interval m3 (-33c)>',
600                          '<music21.interval.Interval M2 (+31c)>'])
601
602    # noinspection SpellCheckingInspection
603    def testScalaScaleB(self):
604        msg = '''! fj-12tet.scl
605!
606Franck Jedrzejewski continued fractions approx. of 12-tet
607 12
608!
60989/84
61055/49
61144/37
61263/50
6134/3
61499/70
615442/295
61627/17
61737/22
61898/55
61915/8
6202/1
621
622'''
623        ss = ScalaData(msg)
624        ss.parse()
625        self.assertEqual(ss.pitchCount, 12)
626        self.assertEqual(ss.fileName, 'fj-12tet.scl')
627        self.assertEqual(ss.description,
628                         'Franck Jedrzejewski continued fractions approx. of 12-tet')
629
630        self.assertEqual([f'{x:.9f}' for x in ss.getCentsAboveTonic()], ['100.099209825',
631                                                                         '199.979843291',
632                                                                         '299.973903610',
633                                                                         '400.108480470',
634                                                                         '498.044999135',
635                                                                         '600.088323762',
636                                                                         '699.997698171',
637                                                                         '800.909593096',
638                                                                         '900.026096390',
639                                                                        '1000.020156709',
640                                                                        '1088.268714730',
641                                                                        '1200.000000000'])
642
643        self.assertEqual([f'{x:.9f}' for x in ss.getAdjacentCents()], ['100.099209825',
644                                                                        '99.880633466',
645                                                                        '99.994060319',
646                                                                       '100.134576860',
647                                                                        '97.936518664',
648                                                                       '102.043324627',
649                                                                        '99.909374409',
650                                                                       '100.911894925',
651                                                                        '99.116503294',
652                                                                        '99.994060319',
653                                                                        '88.248558022',
654                                                                       '111.731285270'])
655
656        self.assertEqual([str(x) for x in ss.getIntervalSequence()],
657                         ['<music21.interval.Interval m2 (+0c)>',
658                          '<music21.interval.Interval m2 (-0c)>',
659                          '<music21.interval.Interval m2 (-0c)>',
660                          '<music21.interval.Interval m2 (+0c)>',
661                          '<music21.interval.Interval m2 (-2c)>',
662                          '<music21.interval.Interval m2 (+2c)>',
663                          '<music21.interval.Interval m2 (-0c)>',
664                          '<music21.interval.Interval m2 (+1c)>',
665                          '<music21.interval.Interval m2 (-1c)>',
666                          '<music21.interval.Interval m2 (-0c)>',
667                          '<music21.interval.Interval m2 (-12c)>',
668                          '<music21.interval.Interval m2 (+12c)>'])
669
670
671        # test loading a new scala object from adjacent sets
672        ss2 = ScalaData()
673        ss2.setAdjacentCents(ss.getAdjacentCents())
674
675        self.assertEqual([f'{x:.9f}' for x in ss2.getCentsAboveTonic()],
676                         [
677                             '100.099209825',
678                             '199.979843291',
679                             '299.973903610',
680                             '400.108480470',
681                             '498.044999135',
682                             '600.088323762',
683                             '699.997698171',
684                             '800.909593096',
685                             '900.026096390',
686                             '1000.020156709',
687                             '1088.268714730',
688                             '1200.000000000'])
689
690    def testScalaFileA(self):
691        # noinspection SpellCheckingInspection
692        msg = '''! arist_chromenh.scl
693!
694Aristoxenos' Chromatic/Enharmonic, 3 + 9 + 18 parts
695 7
696!
697 50.00000
698 200.00000
699 500.00000
700 700.00000
701 750.00000
702 900.00000
703 2/1
704'''
705        sf = ScalaFile()
706        ss = sf.readstr(msg)
707        self.assertEqual(ss.pitchCount, 7)
708
709        # all but last will be the same
710        # print(ss.getFileString())
711        self.assertEqual(ss.getFileString()[:1], msg[:1])
712
713        self.assertEqual([str(x) for x in ss.getIntervalSequence()],
714                         ['<music21.interval.Interval P1 (+50c)>',
715                          '<music21.interval.Interval m2 (+50c)>',
716                          '<music21.interval.Interval m3>',
717                          '<music21.interval.Interval M2>',
718                          '<music21.interval.Interval P1 (+50c)>',
719                          '<music21.interval.Interval m2 (+50c)>',
720                          '<music21.interval.Interval m3>'])
721
722
723# ------------------------------------------------------------------------------
724# define presented order in documentation
725_DOC_ORDER = []
726
727
728if __name__ == '__main__':
729    # sys.arg test options will be used in mainTest()
730    import music21
731    music21.mainTest(Test)
732
733