From 928df1dbb8ed38792a61633b46b98b4e43d3f90f Mon Sep 17 00:00:00 2001 From: hill Date: Mon, 7 Sep 2026 11:52:56 +0200 Subject: [PATCH] Ignore trailing text after a Scala pitch value ScalaData.parse skips a line only when it starts with '!', so a degree line carrying a comment after the value is handed to ScalaPitch, whose _setSrc keeps every digit, '.' and '/' from anywhere in the string. The comment's digits are spliced onto the end of the number rather than dropped, so `10/9 ! A\ ; 8 |mi|la|re|` is read as '10/98' -- -3951.34 cents where 10/9 is 182.40, and no exception is raised. The Scala format ignores any characters following the pitch value, which is why the bundled files write comments there. Take the first whitespace-delimited token on the pitch-count line and on each degree line. 23 of the 3932 files in scale/scala/scl/ were misread. 21 raised ValueError, including every file whose comment contributes a second '/'; 2 more parsed without error and gave wrong cents, newton_15_out_of_53 and sparschuh-jsbloops440. Trailing text without a '!' took the same path: rvf1, rvf2 and rvf3 write a degree as `454.75 (427)`. All of them read correctly now, and no other file's parsed values change. Two tests. testCommentAfterAPitchValue covers a '!' comment, bare trailing text, and the two bundled files that used to be read silently wrong, pinning their cents. testEveryBundledScalaFileParses reads the whole archive and asserts every file declares a pitch count, yields that many values, and gives a finite cents for each -- so a file added or edited upstream that this parser cannot read fails there rather than wherever some scale is later asked for. It takes about 0.8s. The description line is not checked, since the format allows it to be empty. --- music21/scale/scala/__init__.py | 57 +++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/music21/scale/scala/__init__.py b/music21/scale/scala/__init__.py index f16122454..a937287f2 100644 --- a/music21/scale/scala/__init__.py +++ b/music21/scale/scala/__init__.py @@ -288,10 +288,12 @@ def parse(self) -> None: self.description = line elif count == 2: if line != '': - self.pitchCount = int(line) + # only the first token is data; the format ignores any + # trailing characters, so a comment can follow the value + self.pitchCount = int(line.split()[0]) else: # remaining counts are pitches if line != '': - sp = ScalaPitch(line) + sp = ScalaPitch(line.split()[0]) sp.parse() self.pitchValues.append(sp) @@ -744,6 +746,57 @@ def testScalaFileA(self) -> None: '', '']) + def testCommentAfterAPitchValue(self) -> None: + # noinspection SpellCheckingInspection + msg = r'''! commented.scl +! +A scale whose values carry trailing comments + 3 +! + 10/9 ! A\ ; 8 |mi|la|re| + 454.75 (427) + 2/1 ! octave +''' + ss = ScalaData(msg) + ss.parse() + self.assertEqual(ss.pitchCount, 3) + self.assertEqual(len(ss.pitchValues), 3) + self.assertEqual([f'{x.cents:.5f}' for x in ss.pitchValues], + ['182.40371', '454.75000', '1200.00000']) + + # two bundled files the old parser read without raising, and got + # wrong: the comment's digits were appended to the value + newton = parse('newton_15_out_of_53') + assert newton is not None + self.assertEqual(newton.pitchValues[0].src, '10/9') + self.assertEqual(f'{newton.pitchValues[0].cents:.5f}', '182.40371') + + loops = parse('sparschuh-jsbloops440') + assert loops is not None + self.assertEqual(loops.pitchValues[8].src, '1760/1055') + self.assertEqual(f'{loops.pitchValues[8].cents:.5f}', '885.99892') + + def testEveryBundledScalaFileParses(self) -> None: + # a guard on the archive itself: a file added or edited upstream that + # this parser cannot read should fail here rather than at the point + # some scale is asked for. The description line is not checked, as the + # format allows it to be empty. + paths = getPaths() + self.assertGreater(len(paths), 3000) + for fp in paths: + sf = ScalaFile() + sf.open(fp) + try: + ss = sf.read() + finally: + sf.close() + self.assertIsNotNone(ss.pitchCount, fp) + self.assertEqual(len(ss.pitchValues), ss.pitchCount, fp) + for sp in ss.pitchValues: + self.assertIsNotNone(sp.cents, f'{fp}: {sp.src!r}') + self.assertTrue(math.isfinite(t.cast(float, sp.cents)), + f'{fp}: {sp.src!r}') + # ------------------------------------------------------------------------------ # define presented order in documentation