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