From 659e376e586554a5c708943d28ed30d56a8b1bcf Mon Sep 17 00:00:00 2001 From: hill Date: Sat, 10 Oct 2026 15:41:31 +0200 Subject: [PATCH 1/7] Read NWC Dur2/Pos2 chord notes and rest chords at the same offset In a |Chord| line, Dur2/Pos2 is a second group of notes sounding with the chord (a split-stem chord), but translateChord read it as a rest placed after the chord, so the notes were lost and everything after them came late. |RestChord| (a rest with a chord sounding at the same time) was not read at all. Each group now goes in its own voice at the chord's offset; voices are inserted at the start of the measure rather than appended after its notes. --- music21/noteworthy/translate.py | 172 +++++++++++++++++++------------- 1 file changed, 103 insertions(+), 69 deletions(-) diff --git a/music21/noteworthy/translate.py b/music21/noteworthy/translate.py index f4e1cc84be..53b228794b 100644 --- a/music21/noteworthy/translate.py +++ b/music21/noteworthy/translate.py @@ -5,7 +5,7 @@ # Authors: Jordi Bartolome # Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2011-2012 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2011-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -204,6 +204,9 @@ def parseList(self, dataList): self.lyricPosition += 1 elif command == 'Rest': self.translateRest(attributes) + elif command == 'RestChord': + self.translateRestChord(attributes) + self.lyricPosition += 1 elif command == 'SongInfo': self.createSongInfo(attributes) elif command == 'StaffInstrument': @@ -520,76 +523,66 @@ def translateChord(self, attributes): >>> measure[1] ''' - durationInfos = attributes['Dur'] - pitchInfos = attributes['Pos'] - currentDuration = self.getShortestStream().duration - isRestChord = 'Dur2' in attributes - i = 0 - - def getVoiceAtDuration(inner_self, voiceId, dur): - # first check if voice already exists in measure - voice = None - voices = inner_self.currentMeasure.getElementsByClass(stream.Voice) - voice = voices.getElementById(voiceId) - - # otherwise create it - if voice is None: - voice = stream.Voice() - voice.id = voiceId - # if creating the first voice, add current measure contents to it - if len(voices) == 0: - notes = [] - for item in inner_self.currentMeasure: - if isinstance(item, note.GeneralNote): - notes.append(item) - if notes: - voice.append(notes) - inner_self.currentMeasure.remove(notes) - - inner_self.currentMeasure.append(voice) - - - cd = dur.quarterLength - vd = voice.quarterLength - # if current voice is late, add rest - if cd - vd > 0: - rest = note.Rest() - rest.quarterLength = cd - vd - rest.stepShift = 3 - voice.append(rest) - - return voice - - - for d in durationInfos: - c = chord.Chord() # note! - # durationInfo - self.setDurationForObject(c, d) - - # pitchInfo - p = pitchInfos[durationInfos.index(d)] - c.pitches = self.getMultiplePitchesFromPositionInfo(p) - self.setTieFromPitchInfo(c, p) - - # if Lyrics - if self.lyrics and self.lyricPosition < len(self.lyrics): - c.addLyric(self.lyrics[self.lyricPosition]) - - if len(durationInfos) == 1 and isRestChord is not None: - self.getShortestStream().append(c) - else: - v = getVoiceAtDuration(self, i, currentDuration) - v.append(c) + groups = list(zip(attributes['Dur'], attributes['Pos'])) + if 'Dur2' in attributes: + groups.append((attributes['Dur2'], attributes['Pos2'])) + chords = [self.createChord(durationInfo, pitchInfo) + for durationInfo, pitchInfo in groups] + self.appendSimultaneous(chords) + + def translateRestChord(self, attributes: dict[str, str]) -> None: + ''' + Translation of a NWC rest chord: a rest and the chord sounding with it. + ''' + c = self.createChord(attributes['Dur2'], attributes['Pos2']) + r = note.Rest() + self.setDurationForObject(r, attributes['Dur']) + self.appendSimultaneous([c, r]) - i += 1 + def createChord(self, durationInfo: str, pitchInfo: str) -> chord.Chord: + ''' + Returns a chord from the Dur and Pos info of one group of NWC chord notes. + ''' + c = chord.Chord() + self.setDurationForObject(c, durationInfo) + c.pitches = self.getMultiplePitchesFromPositionInfo(pitchInfo) + self.setTieFromPitchInfo(c, pitchInfo) + return c - if isRestChord: - restDurInfo = attributes['Dur2'] - r = note.Rest() - r.stepShift = 3 - self.setDurationForObject(r, restDurInfo) - v = getVoiceAtDuration(self, i, currentDuration) - v.append(r) + def appendSimultaneous(self, generalNotes: list[note.GeneralNote]) -> None: + ''' + Appends notes, chords or rests that start together, each in its own + voice when there is more than one. The first one gets the current lyric. + + AI-assisted (Claude). + ''' + if self.lyrics and self.lyricPosition < len(self.lyrics): + generalNotes[0].addLyric(self.lyrics[self.lyricPosition]) + + shortestStream = self.getShortestStream() + if len(generalNotes) == 1: + shortestStream.append(generalNotes[0]) + return + + offset = shortestStream.highestTime + measure = self.currentMeasure + voices = list(measure.voices) + if not voices: + firstVoice = stream.Voice(id=0) + notes = list(measure.getElementsByClass(note.GeneralNote)) + measure.remove(notes) + firstVoice.append(notes) + measure.insert(0, firstVoice) + voices.append(firstVoice) + freeVoices = [v for v in voices if v.highestTime == offset] + for gn in generalNotes: + if freeVoices: + voice = freeVoices.pop(0) + else: + voice = stream.Voice(id=len(voices)) + measure.insert(0, voice) + voices.append(voice) + voice.insert(offset, gn) def translateRest(self, attributes): r''' @@ -994,6 +987,47 @@ def testBasic(self): self.assertEqual(str(myScore[clef.Clef].first()), '') + @staticmethod + def firstMeasureContents(lines: list[str]) -> list[tuple[float, float, str]]: + ''' + The offset, length and pitches of each note and rest in the first measure. + + AI-assisted (Claude). + ''' + s = NoteworthyTranslator().parseList(['|AddStaff|', '|Clef|Type:Treble'] + lines) + m = s.parts.first().getElementsByClass(stream.Measure).first() + return sorted((float(e.getOffsetInHierarchy(m)), float(e.quarterLength), + ' '.join(p.nameWithOctave for p in e.pitches) or 'rest') + for e in m.recurse().notesAndRests) + + def testChordWithTwoDurations(self): + self.assertEqual(self.firstMeasureContents([ + '|Note|Dur:4th|Pos:0', + '|Chord|Dur:8th|Pos:-4,n-3,b-2,#-1,x0,v1,2x|Opts:Stem=Down,Crescendo' + + '|Dur2:8th,DblDotted|Pos2:3x', + '|Note|Dur:4th|Pos:-5', + ]), [ + (0.0, 1.0, 'B4'), + (1.0, 0.5, 'E4 F4 G-4 A#4 B##4 C--5 D5'), + (1.0, 0.875, 'E5'), + (1.5, 1.0, 'D4'), + ]) + + def testRestChord(self): + self.assertEqual(self.firstMeasureContents([ + '|Note|Dur:4th|Pos:0', + '|RestChord|Dur:Half|Opts:Stem=Up|Dur2:4th|Pos2:-1,1', + '|RestChord|Dur:4th|Dur2:4th|Pos2:-5', + '|Note|Dur:4th|Pos:0', + ]), [ + (0.0, 1.0, 'B4'), + (1.0, 1.0, 'A4 C5'), + (1.0, 2.0, 'rest'), + (2.0, 1.0, 'D4'), + (2.0, 1.0, 'rest'), + (3.0, 1.0, 'B4'), + ]) + def testKeySignatureAtBeginning(self): ''' test a problem with accidentals at the end of one staff not From c05fa3d8248e86552b3e5c32234162ba9e978460 Mon Sep 17 00:00:00 2001 From: hill Date: Sat, 31 Oct 2026 07:45:44 +0100 Subject: [PATCH 2/7] Keep NWC groups at a running position, ties and slurs in their own voice Notes after a chord group start where the shortest group ends, not at the end of the shortest voice. Ties follow their staff position and slurs their voice. Binary rest chords are dumped as |RestChord|. --- music21/noteworthy/binaryTranslate.py | 24 +- music21/noteworthy/translate.py | 301 ++++++++++++++++++-------- 2 files changed, 220 insertions(+), 105 deletions(-) diff --git a/music21/noteworthy/binaryTranslate.py b/music21/noteworthy/binaryTranslate.py index dd0a7dd99d..61955931eb 100644 --- a/music21/noteworthy/binaryTranslate.py +++ b/music21/noteworthy/binaryTranslate.py @@ -1295,23 +1295,13 @@ def restChordMember(self): self.data2.append(rest) def dump(inner_self): - build = '|Chord' - notes = {} - for d in inner_self.data2: - if notes.get(d.durationStr) is None: - notes[d.durationStr] = [] - - notes[d.durationStr].append(d.alterationStr + str(d.pos) + d.tieInfo) - - i = 0 - for n in notes: - if i == len(notes) - 1: - build += '|Dur2:' + n + '|Pos2:' + ','.join(notes[n]) - else: - build += '|Dur:' + n + '|Pos:' + ','.join(notes[n]) - i += 1 - - return build + *chordNotes, chordRest = inner_self.data2 + if not chordNotes: + return '|Rest|Dur:' + chordRest.durationStr + return ('|RestChord|Dur:' + chordRest.durationStr + + '|Dur2:' + chordNotes[0].durationStr + + '|Pos2:' + ','.join(d.alterationStr + str(d.pos) + d.tieInfo + for d in chordNotes)) self.dumpMethod = dump diff --git a/music21/noteworthy/translate.py b/music21/noteworthy/translate.py index 53b228794b..3e27d30ed5 100644 --- a/music21/noteworthy/translate.py +++ b/music21/noteworthy/translate.py @@ -58,6 +58,7 @@ # |Chord|Dur:8th|Pos:-4,n-3,b-2,#-1,x0,v1,2x|Opts:Stem=Down,Crescendo|Dur2:8th,DblDotted|Pos2:3x from __future__ import annotations +import re import unittest from music21 import bar @@ -82,6 +83,8 @@ from music21 import tempo from music21 import tie +from music21.common.numberTools import opFrac +from music21.common.types import OffsetQL from music21.noteworthy.dictionaries import dictionaries environLocal = environment.Environment('noteworthy.translate') @@ -108,9 +111,10 @@ def __init__(self): self.currentClef = 'TREBLE' self.currentKey = key.KeySignature(0) - self.withinSlur = False - self.beginningSlurNote = None - self.withinTie = False + self.slurStarts: dict[int, note.GeneralNote] = {} + self.tiedPositions: set[str] = set() + self.positionMeasure: stream.Measure|None = None + self.measurePosition: OffsetQL = 0.0 self.lyricPosition = 0 self.lyrics = [] @@ -248,16 +252,11 @@ def setDurationForObject(self, generalNote, durationInfo): ''' parts = durationInfo.split(',') lengthNote = parts[0] - thisNoteIsSlurred = False durationObject = duration.Duration(dictionaries['dictionaryNoteLength'][lengthNote]) for kk in parts: if kk == 'Grace': durationObject = durationObject.getGraceDuration() - elif kk == 'Slur': - if not self.withinSlur: - self.beginningSlurNote = generalNote - thisNoteIsSlurred = True elif kk == 'Dotted': durationObject.dots = 1 elif kk == 'DblDotted': @@ -269,46 +268,37 @@ def setDurationForObject(self, generalNote, durationInfo): generalNote.duration = durationObject - # if Slur - if self.withinSlur and not thisNoteIsSlurred: - music21SlurObj = spanner.Slur(self.beginningSlurNote, generalNote) - self.currentMeasure.append(music21SlurObj) - self.withinSlur = False - elif thisNoteIsSlurred: - self.withinSlur = True - else: - self.withinSlur = False - def setTieFromPitchInfo(self, noteOrChord, pitchInfo): ''' - sets the tie status for a noteOrChord from the pitchInfo - ''' - thisNoteBeginsATie = False - thisNoteIsTied = False - - if pitchInfo[-1] == '^': - if self.withinTie is False: - thisNoteBeginsATie = True - thisNoteIsTied = True - self.withinTie = True - - achord = noteOrChord if isinstance(noteOrChord, chord.Chord) else None - - # if Tied - if thisNoteBeginsATie: - if achord is not None: - for n in achord: - n.tie = tie.Tie('start') - else: - noteOrChord.tie = tie.Tie('start') + Sets the ties of a note or chord from its Pos info. A position ending + in ``^`` is tied to the next note at that position, whatever group or + voice it comes in. - if self.withinTie is True and thisNoteIsTied is False: - if achord is not None: - for n in achord: - n.tie = tie.Tie('stop') + >>> nwt = noteworthy.translate.NoteworthyTranslator() + >>> c = chord.Chord('C5 E5') + >>> nwt.setTieFromPitchInfo(c, '1^,3') + >>> [n.tie for n in c] + [, None] + >>> c = chord.Chord('C5 E5') + >>> nwt.setTieFromPitchInfo(c, '1,3') + >>> [n.tie for n in c] + [, None] + ''' + notes = list(noteOrChord) if isinstance(noteOrChord, chord.Chord) else [noteOrChord] + for n, posInfo in zip(notes, pitchInfo.split(',')): + tiedOn = posInfo.endswith('^') + position = re.search(r'-?\d+', posInfo).group() + tiedFrom = position in self.tiedPositions + if tiedFrom and tiedOn: + n.tie = tie.Tie('continue') + elif tiedOn: + n.tie = tie.Tie('start') + elif tiedFrom: + n.tie = tie.Tie('stop') + if tiedOn: + self.tiedPositions.add(position) else: - noteOrChord.tie = tie.Tie('stop') - self.withinTie = False + self.tiedPositions.discard(position) def getPitchFromPositionInfo(self, posInfo): # noinspection PyShadowingNames @@ -487,24 +477,8 @@ def translateNote(self, attributes): # pitchInfo self.setTieFromPitchInfo(n, pitchInfo) - # if Lyrics - if self.lyrics and self.lyricPosition < len(self.lyrics): - n.addLyric(self.lyrics[self.lyricPosition]) - - self.getShortestStream().append(n) - - # Returns the shortest stream. It can be the shortest Voice - # or current measure if no voice. - def getShortestStream(self): - if self.currentMeasure.isFlat: - # O(1) in most cases. - return self.currentMeasure - - # O(n) only if voices already exist. - return min( - self.currentMeasure.getElementsByClass(stream.Voice), - key=lambda v: v.quarterLength - ) + self.addCurrentLyric(n) + self.appendSimultaneous([(n, durationInfo)]) def translateChord(self, attributes): r''' @@ -526,22 +500,41 @@ def translateChord(self, attributes): groups = list(zip(attributes['Dur'], attributes['Pos'])) if 'Dur2' in attributes: groups.append((attributes['Dur2'], attributes['Pos2'])) - chords = [self.createChord(durationInfo, pitchInfo) + chords = [(self.createChord(durationInfo, pitchInfo), durationInfo) for durationInfo, pitchInfo in groups] + self.addCurrentLyric(chords[0][0]) self.appendSimultaneous(chords) def translateRestChord(self, attributes: dict[str, str]) -> None: ''' Translation of a NWC rest chord: a rest and the chord sounding with it. + + >>> nwt = noteworthy.translate.NoteworthyTranslator() + >>> nwt.currentMeasure = stream.Measure() + >>> nwt.translateRestChord({'Dur': 'Half', 'Dur2': '4th', 'Pos2': '-1,1'}) + >>> nwt.currentMeasure.show('text') + {0.0} + {0.0} + {0.0} + {0.0} + + * New in v11. ''' c = self.createChord(attributes['Dur2'], attributes['Pos2']) r = note.Rest() self.setDurationForObject(r, attributes['Dur']) - self.appendSimultaneous([c, r]) + self.addCurrentLyric(c) + self.appendSimultaneous([(c, attributes['Dur2']), (r, attributes['Dur'])]) def createChord(self, durationInfo: str, pitchInfo: str) -> chord.Chord: ''' Returns a chord from the Dur and Pos info of one group of NWC chord notes. + + >>> nwt = noteworthy.translate.NoteworthyTranslator() + >>> nwt.createChord('8th', '-1,1^') + + + * New in v11. ''' c = chord.Chord() self.setDurationForObject(c, durationInfo) @@ -549,40 +542,82 @@ def createChord(self, durationInfo: str, pitchInfo: str) -> chord.Chord: self.setTieFromPitchInfo(c, pitchInfo) return c - def appendSimultaneous(self, generalNotes: list[note.GeneralNote]) -> None: + def addCurrentLyric(self, generalNote: note.GeneralNote) -> None: ''' - Appends notes, chords or rests that start together, each in its own - voice when there is more than one. The first one gets the current lyric. + Gives a note or chord the current lyric syllable, if there is one. - AI-assisted (Claude). + * New in v11. ''' if self.lyrics and self.lyricPosition < len(self.lyrics): - generalNotes[0].addLyric(self.lyrics[self.lyricPosition]) + generalNote.addLyric(self.lyrics[self.lyricPosition]) - shortestStream = self.getShortestStream() - if len(generalNotes) == 1: - shortestStream.append(generalNotes[0]) - return + def appendSimultaneous(self, generalNotes: list[tuple[note.GeneralNote, str]]) -> None: + ''' + Places notes, chords or rests, each with its Dur info, at the current + position in the measure, and moves the position on by the shortest of + them. When more than one starts together, or a voice is still + sounding, each goes in a voice that is free at that position. A slur + in the Dur info runs to the next note or rest in the same voice. - offset = shortestStream.highestTime + >>> nwt = noteworthy.translate.NoteworthyTranslator() + >>> nwt.currentMeasure = stream.Measure() + >>> nwt.appendSimultaneous([(note.Note('C5', type='half'), 'Half'), + ... (note.Note('A4'), '4th')]) + >>> nwt.appendSimultaneous([(note.Note('B4'), '4th')]) + >>> nwt.currentMeasure.show('text') + {0.0} + {0.0} + {0.0} + {0.0} + {1.0} + + * New in v11. + ''' measure = self.currentMeasure + if self.positionMeasure is not measure: + self.positionMeasure = measure + self.measurePosition = measure.highestTime + offset = self.measurePosition + voices = list(measure.voices) - if not voices: + if not voices and len(generalNotes) > 1: firstVoice = stream.Voice(id=0) - notes = list(measure.getElementsByClass(note.GeneralNote)) - measure.remove(notes) - firstVoice.append(notes) + notes = [(n.offset, n) for n in measure.getElementsByClass(note.GeneralNote)] + measure.remove([n for unused, n in notes]) + for noteOffset, n in notes: + firstVoice.insert(noteOffset, n) measure.insert(0, firstVoice) voices.append(firstVoice) - freeVoices = [v for v in voices if v.highestTime == offset] - for gn in generalNotes: - if freeVoices: - voice = freeVoices.pop(0) + # the voices free at the offset, those that end there first + freeVoices = sorted((v for v in voices if v.highestTime <= offset), + key=lambda v: -v.highestTime) + for gn, durationInfo in generalNotes: + if not voices: + measure.insert(offset, gn) + voiceId = 0 else: - voice = stream.Voice(id=len(voices)) - measure.insert(0, voice) - voices.append(voice) - voice.insert(offset, gn) + if freeVoices: + voice = freeVoices.pop(0) + else: + voice = stream.Voice(id=len(voices)) + measure.insert(0, voice) + voices.append(voice) + voice.insert(offset, gn) + voiceId = voice.id + self.setSlur(gn, voiceId, 'Slur' in durationInfo.split(',')) + self.measurePosition = opFrac(offset + min(gn.quarterLength for gn, unused in generalNotes)) + + def setSlur(self, generalNote: note.GeneralNote, voiceId: int, slurred: bool) -> None: + ''' + Starts, continues or ends a slur in one voice: a slur runs from the + first slurred note in a row to the next note that is not slurred. + + * New in v11. + ''' + if slurred: + self.slurStarts.setdefault(voiceId, generalNote) + elif voiceId in self.slurStarts: + self.currentMeasure.append(spanner.Slur(self.slurStarts.pop(voiceId), generalNote)) def translateRest(self, attributes): r''' @@ -604,8 +639,7 @@ def translateRest(self, attributes): r = note.Rest() self.setDurationForObject(r, durationInfo) - - self.getShortestStream().append(r) + self.appendSimultaneous([(r, durationInfo)]) def createClef(self, attributes): r''' @@ -1028,6 +1062,97 @@ def testRestChord(self): (3.0, 1.0, 'B4'), ]) + def testNotesAfterGroupsFollowThePosition(self): + self.assertEqual(self.firstMeasureContents([ + '|Chord|Dur:8th|Pos:-4|Dur2:8th,DblDotted|Pos2:3', + '|Note|Dur:4th|Pos:-5', + '|Note|Dur:4th|Pos:-4', + ]), [ + (0.0, 0.5, 'E4'), + (0.0, 0.875, 'E5'), + (0.5, 1.0, 'D4'), + (1.5, 1.0, 'E4'), + ]) + self.assertEqual(self.firstMeasureContents([ + '|RestChord|Dur:Half|Dur2:4th|Pos2:-1,1', + '|Note|Dur:Half|Pos:0', + '|Note|Dur:4th|Pos:1', + ]), [ + (0.0, 1.0, 'A4 C5'), + (0.0, 2.0, 'rest'), + (1.0, 2.0, 'B4'), + (3.0, 1.0, 'C5'), + ]) + # notes moved into the first voice keep their offsets + self.assertEqual(self.firstMeasureContents([ + '|Note|Dur:4th,Triplet|Pos:0', + '|Note|Dur:4th,Triplet|Pos:1', + '|Note|Dur:4th,Triplet|Pos:2', + '|Chord|Dur:4th|Pos:-1|Dur2:Half|Pos2:3', + ]), [ + (0.0, 2 / 3, 'B4'), + (2 / 3, 2 / 3, 'C5'), + (4 / 3, 2 / 3, 'D5'), + (2.0, 1.0, 'A4'), + (2.0, 2.0, 'E5'), + ]) + + @staticmethod + def parseTreble(lines: list[str]) -> stream.Score: + return NoteworthyTranslator().parseList(['|AddStaff|', '|Clef|Type:Treble'] + lines) + + def testTiesFollowTheirPositions(self): + def ties(lines): + return sorted((n.pitch.nameWithOctave, float(c.getOffsetInHierarchy(s)), + n.tie.type if n.tie else None) + for s in [self.parseTreble(lines)] + for c in s.recurse().getElementsByClass(chord.Chord) + for n in c) + + self.assertEqual(ties([ + '|Chord|Dur:4th|Pos:1^|Dur2:4th|Pos2:3', + '|Chord|Dur:4th|Pos:1|Dur2:4th|Pos2:3', + ]), [('C5', 0.0, 'start'), ('C5', 1.0, 'stop'), ('E5', 0.0, None), ('E5', 1.0, None)]) + self.assertEqual(ties([ + '|Chord|Dur:4th|Pos:1^|Dur2:4th|Pos2:3^', + '|Chord|Dur:4th|Pos:1|Dur2:4th|Pos2:3', + ]), [('C5', 0.0, 'start'), ('C5', 1.0, 'stop'), + ('E5', 0.0, 'start'), ('E5', 1.0, 'stop')]) + + def testSlursStayInTheirVoice(self): + def slurs(lines): + return [[repr(e) for e in sl.getSpannedElements()] + for sl in self.parseTreble(lines).recurse().getElementsByClass(spanner.Slur)] + + self.assertEqual(slurs([ + '|RestChord|Dur:Half|Dur2:4th,Slur|Pos2:-1,1', + '|Note|Dur:4th|Pos:0', + ]), [['', '']]) + self.assertEqual(slurs([ + '|Chord|Dur:4th,Slur|Pos:1|Dur2:4th|Pos2:3', + '|Chord|Dur:4th|Pos:1|Dur2:4th|Pos2:3', + ]), [['', '']]) + + def testRestChordFromBinary(self): + import struct + from music21.noteworthy import binaryTranslate + # a rest chord in a version 1.75 file: object type 18, visibility, ten + # data bytes (the rest's duration first, the number of notes ninth), + # then a quarter note at staff position 0 + for restDuration, dumped, restLength in ( + (1, '|RestChord|Dur:Half|Dur2:4th|Pos2:0', 2.0), + (2, '|RestChord|Dur:4th|Dur2:4th|Pos2:0', 1.0)): + data = struct.pack(' Date: Sat, 31 Oct 2026 07:45:47 +0100 Subject: [PATCH 3/7] Bump to 11.0.0b15: NWC chords and rest chords read differently --- music21/_version.py | 2 +- music21/base.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/music21/_version.py b/music21/_version.py index fc23b05011..cb79fa5eeb 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -47,7 +47,7 @@ ''' from __future__ import annotations -__version__ = '11.0.0b14' +__version__ = '11.0.0b15' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/base.py b/music21/base.py index d2b910b420..e6254a1e20 100644 --- a/music21/base.py +++ b/music21/base.py @@ -26,7 +26,7 @@ >>> music21.VERSION_STR -'11.0.0b14' +'11.0.0b15' Alternatively, after doing a complete import, these classes are available under the module "base": From b16a344f29dd6ac04db8fe8901acefe7d6d46bbe Mon Sep 17 00:00:00 2001 From: hill Date: Sun, 1 Nov 2026 02:52:36 +0100 Subject: [PATCH 4/7] Fix NWC slurs, direction offsets and ties around chord groups Slurs: a slurred note is slurred to the next note that starts once it has ended, in any voice or measure. Slurred notes in a row still make one slur. Before, slurs were kept per voice, so |Chord|Dur:Half|Pos:1|Dur2:4th|Pos2:3, |Note|Dur:4th,Slur|Pos:2, |Note|Dur:4th|Pos:4 had no slur (also with a |Bar| before the last note), and a slur from a Dur2 group ran to the next chord three bars later instead of the next note. Clefs, keys, time signatures, dynamics, hairpins, repeat marks and text go in at the running measure position, not at the end of the longest group. |Chord|Dur:4th|Pos:-1|Dur2:Whole|Pos2:3, |Dynamic|Style:p, |Note|Dur:4th|Pos:1 put the p at 4.0; it is now at 1.0 with the note. Ties match on step and octave, so they survive a clef change (|Note|Dur:4th|Pos:-6^, |Clef|Type:Bass, |Note|Dur:4th|Pos:6 is C4 to C4) and a sharp tied over a barline still gets its stop. A tie only stops on a note that starts where the tied note ends, so Pos:1^, Pos:2, Pos:1 no longer puts a stop on the third note. Open ties and slurs are cleared at a new staff. --- music21/noteworthy/translate.py | 225 +++++++++++++++++++++++++------- 1 file changed, 181 insertions(+), 44 deletions(-) diff --git a/music21/noteworthy/translate.py b/music21/noteworthy/translate.py index 3e27d30ed5..e384820415 100644 --- a/music21/noteworthy/translate.py +++ b/music21/noteworthy/translate.py @@ -62,6 +62,7 @@ import unittest from music21 import bar +from music21 import base from music21 import chord from music21 import clef from music21 import common @@ -111,8 +112,8 @@ def __init__(self): self.currentClef = 'TREBLE' self.currentKey = key.KeySignature(0) - self.slurStarts: dict[int, note.GeneralNote] = {} - self.tiedPositions: set[str] = set() + self.openSlurs: list[tuple[note.GeneralNote, stream.Measure, OffsetQL]] = [] + self.tiedSteps: dict[tuple[str, int], tuple[stream.Measure, OffsetQL]] = {} self.positionMeasure: stream.Measure|None = None self.measurePosition: OffsetQL = 0.0 @@ -270,25 +271,36 @@ def setDurationForObject(self, generalNote, durationInfo): def setTieFromPitchInfo(self, noteOrChord, pitchInfo): ''' - Sets the ties of a note or chord from its Pos info. A position ending - in ``^`` is tied to the next note at that position, whatever group or - voice it comes in. + Sets the ties of a note or chord, about to go at the current position, + from its Pos info. A position ending in ``^`` is tied to the note on + the same staff line or space, under whatever clef, that starts where + the tied note ends, whatever group or voice it comes in. >>> nwt = noteworthy.translate.NoteworthyTranslator() + >>> nwt.currentMeasure = stream.Measure() >>> c = chord.Chord('C5 E5') >>> nwt.setTieFromPitchInfo(c, '1^,3') >>> [n.tie for n in c] [, None] + >>> nwt.appendSimultaneous([(c, '4th')]) >>> c = chord.Chord('C5 E5') >>> nwt.setTieFromPitchInfo(c, '1,3') >>> [n.tie for n in c] [, None] ''' + offset = self.currentPosition() + end = opFrac(offset + noteOrChord.quarterLength) notes = list(noteOrChord) if isinstance(noteOrChord, chord.Chord) else [noteOrChord] for n, posInfo in zip(notes, pitchInfo.split(',')): tiedOn = posInfo.endswith('^') - position = re.search(r'-?\d+', posInfo).group() - tiedFrom = position in self.tiedPositions + step = self.getStepAndOctaveFromPosition(int(re.search(r'-?\d+', posInfo).group())) + tiedFrom = False + if step in self.tiedSteps: + tiedMeasure, tiedEnd = self.tiedSteps.pop(step) + if tiedMeasure is self.currentMeasure: + tiedFrom = (tiedEnd == offset) + else: + tiedFrom = (offset == 0) if tiedFrom and tiedOn: n.tie = tie.Tie('continue') elif tiedOn: @@ -296,9 +308,7 @@ def setTieFromPitchInfo(self, noteOrChord, pitchInfo): elif tiedFrom: n.tie = tie.Tie('stop') if tiedOn: - self.tiedPositions.add(position) - else: - self.tiedPositions.discard(position) + self.tiedSteps[step] = (self.currentMeasure, end) def getPitchFromPositionInfo(self, posInfo): # noinspection PyShadowingNames @@ -556,8 +566,8 @@ def appendSimultaneous(self, generalNotes: list[tuple[note.GeneralNote, str]]) - Places notes, chords or rests, each with its Dur info, at the current position in the measure, and moves the position on by the shortest of them. When more than one starts together, or a voice is still - sounding, each goes in a voice that is free at that position. A slur - in the Dur info runs to the next note or rest in the same voice. + sounding, each goes in a voice that is free at that position. Slurs + in the Dur info are set with :meth:`setSlurs`. >>> nwt = noteworthy.translate.NoteworthyTranslator() >>> nwt.currentMeasure = stream.Measure() @@ -574,10 +584,7 @@ def appendSimultaneous(self, generalNotes: list[tuple[note.GeneralNote, str]]) - * New in v11. ''' measure = self.currentMeasure - if self.positionMeasure is not measure: - self.positionMeasure = measure - self.measurePosition = measure.highestTime - offset = self.measurePosition + offset = self.currentPosition() voices = list(measure.voices) if not voices and len(generalNotes) > 1: @@ -588,13 +595,11 @@ def appendSimultaneous(self, generalNotes: list[tuple[note.GeneralNote, str]]) - firstVoice.insert(noteOffset, n) measure.insert(0, firstVoice) voices.append(firstVoice) - # the voices free at the offset, those that end there first freeVoices = sorted((v for v in voices if v.highestTime <= offset), key=lambda v: -v.highestTime) - for gn, durationInfo in generalNotes: + for gn, unused_durationInfo in generalNotes: if not voices: measure.insert(offset, gn) - voiceId = 0 else: if freeVoices: voice = freeVoices.pop(0) @@ -603,21 +608,81 @@ def appendSimultaneous(self, generalNotes: list[tuple[note.GeneralNote, str]]) - measure.insert(0, voice) voices.append(voice) voice.insert(offset, gn) - voiceId = voice.id - self.setSlur(gn, voiceId, 'Slur' in durationInfo.split(',')) + self.setSlurs([(gn, 'Slur' in durationInfo.split(',')) + for gn, durationInfo in generalNotes]) self.measurePosition = opFrac(offset + min(gn.quarterLength for gn, unused in generalNotes)) - def setSlur(self, generalNote: note.GeneralNote, voiceId: int, slurred: bool) -> None: + def setSlurs(self, generalNotes: list[tuple[note.GeneralNote, bool]]) -> None: ''' - Starts, continues or ends a slur in one voice: a slur runs from the - first slurred note in a row to the next note that is not slurred. + Starts, continues or ends slurs at notes, chords or rests just placed + together at the current position, each with whether it is slurred. A + slurred note is slurred to the next note that starts once it has + ended, in any voice, and slurred notes in a row make one slur. + + >>> nwt = noteworthy.translate.NoteworthyTranslator() + >>> nwt.currentMeasure = stream.Measure() + >>> nwt.appendSimultaneous([(note.Note('C5', type='half'), 'Half'), + ... (note.Note('A4'), '4th,Slur')]) + >>> nwt.appendSimultaneous([(note.Note('B4'), '4th,Slur')]) + >>> nwt.appendSimultaneous([(note.Note('D5'), '4th')]) + >>> nwt.currentMeasure.getElementsByClass(spanner.Slur).first() + > * New in v11. ''' - if slurred: - self.slurStarts.setdefault(voiceId, generalNote) - elif voiceId in self.slurStarts: - self.currentMeasure.append(spanner.Slur(self.slurStarts.pop(voiceId), generalNote)) + offset = self.currentPosition() + ended = [openSlur for openSlur in self.openSlurs + if openSlur[1] is not self.currentMeasure or openSlur[2] <= offset] + for gn, slurred in sorted(generalNotes, key=lambda gnSlurred: not gnSlurred[1]): + end = opFrac(offset + gn.quarterLength) + if slurred and ended: + start = ended.pop(0)[0] + self.openSlurs = [openSlur for openSlur in self.openSlurs + if openSlur[0] is not start] + self.openSlurs.append((start, self.currentMeasure, end)) + elif slurred: + self.openSlurs.append((gn, self.currentMeasure, end)) + for start, unused_measure, unused_end in ended: + self.currentMeasure.append(spanner.Slur(start, gn)) + self.openSlurs = [openSlur for openSlur in self.openSlurs + if openSlur[0] is not start] + ended = [] + + def currentPosition(self) -> OffsetQL: + ''' + Returns the offset in the current measure where the next note, chord, + rest, clef or other object goes: the end of the measure's contents + when first used in a measure, then the position moved on by + :meth:`appendSimultaneous`. + + >>> nwt = noteworthy.translate.NoteworthyTranslator() + >>> nwt.currentMeasure = stream.Measure() + >>> nwt.appendSimultaneous([(note.Note(type='half'), 'Half'), (note.Note(), '4th')]) + >>> nwt.currentPosition() + 1.0 + + * New in v11. + ''' + if self.positionMeasure is not self.currentMeasure: + self.positionMeasure = self.currentMeasure + self.measurePosition = self.currentMeasure.highestTime + return self.measurePosition + + def insertAtPosition(self, element: base.Music21Object) -> None: + ''' + Inserts a clef, key, dynamic or other object into the current measure + at the current position. + + >>> nwt = noteworthy.translate.NoteworthyTranslator() + >>> nwt.currentMeasure = stream.Measure() + >>> nwt.appendSimultaneous([(note.Note(type='half'), 'Half'), (note.Note(), '4th')]) + >>> nwt.insertAtPosition(dynamics.Dynamic('p')) + >>> nwt.currentMeasure.getElementsByClass(dynamics.Dynamic).first().offset + 1.0 + + * New in v11. + ''' + self.currentMeasure.insert(self.currentPosition(), element) def translateRest(self, attributes): r''' @@ -687,40 +752,40 @@ def createClef(self, attributes): cl = attributes['Type'] if cl == 'Treble': if octaveShift == 0: - self.currentMeasure.append(clef.TrebleClef()) + self.insertAtPosition(clef.TrebleClef()) currentClef = 'TREBLE' elif octaveShift == -1: - self.currentMeasure.append(clef.Treble8vbClef()) + self.insertAtPosition(clef.Treble8vbClef()) currentClef = 'TREBLE8dw' elif octaveShift == 1: - self.currentMeasure.append(clef.Treble8vaClef()) + self.insertAtPosition(clef.Treble8vaClef()) currentClef = 'TREBLE8up' elif cl == 'Bass': if octaveShift == 0: - self.currentMeasure.append(clef.BassClef()) + self.insertAtPosition(clef.BassClef()) currentClef = 'BASS' elif octaveShift == -1: - self.currentMeasure.append(clef.Bass8vbClef()) + self.insertAtPosition(clef.Bass8vbClef()) currentClef = 'BASS8dw' elif octaveShift == 1: - self.currentMeasure.append(clef.Bass8vaClef()) + self.insertAtPosition(clef.Bass8vaClef()) currentClef = 'BASS8up' elif cl == 'Alto': if octaveShift != 0: raise NoteworthyTranslateException('cannot shift octaves on an alto clef') - self.currentMeasure.append(clef.AltoClef()) + self.insertAtPosition(clef.AltoClef()) currentClef = 'ALTO' elif cl == 'Tenor': if octaveShift != 0: raise NoteworthyTranslateException('cannot shift octaves on a tenor clef') - self.currentMeasure.append(clef.TenorClef()) + self.insertAtPosition(clef.TenorClef()) currentClef = 'TENOR' elif cl == 'Percussion': c = clef.PercussionClef() c.line = 2 - self.currentMeasure.append(c) + self.insertAtPosition(c) currentClef = 'PERCUSSION' if currentClef is None: @@ -752,7 +817,7 @@ def createKey(self, attributes): elif attribute == 'b': currentSharps -= 1 currentKey = key.KeySignature(currentSharps) - self.currentMeasure.append(currentKey) + self.insertAtPosition(currentKey) self.currentKey = currentKey def createTimeSignature(self, attributes): @@ -773,7 +838,7 @@ def createTimeSignature(self, attributes): times = '4/4' m = meter.TimeSignature(times) - self.currentMeasure.append(m) + self.insertAtPosition(m) def createPart(self): ''' @@ -885,7 +950,7 @@ def createOtherRepetitions(self, attributes): g = repeat.Fine() else: raise NoteworthyTranslateException(f'Cannot get style from {str(attributes)}') - self.currentMeasure.append(g) + self.insertAtPosition(g) def createDynamicVariance(self, attributes): r''' @@ -907,7 +972,7 @@ def createDynamicVariance(self, attributes): pass # raise NoteworthyTranslateException(f'Cannot get style from {attributes}') if g is not None: - self.currentMeasure.append(g) + self.insertAtPosition(g) def createDynamics(self, attributes): r''' @@ -922,7 +987,7 @@ def createDynamics(self, attributes): # Dynamic case if 'Style' in attributes: g = dynamics.Dynamic(attributes['Style']) - self.currentMeasure.append(g) + self.insertAtPosition(g) def createLyrics(self, attributes): r''' @@ -960,6 +1025,8 @@ def createLyrics(self, attributes): def createStaff(self, attributes): self.createPart() + self.openSlurs = [] + self.tiedSteps = {} self.currentKey = key.KeySignature(0) self.activeAccidentals = {} self.lyrics = [] @@ -981,7 +1048,7 @@ def createText(self, attributes): expr = expressions.TextExpression(text) expr.placement = 'above' if int(attributes.get('Pos')) < 0 else 'below' expr.style.relativeY = attributes.get('Pos') - self.currentMeasure.append(expr) + self.insertAtPosition(expr) def createSongInfo(self, attributes): title = attributes.get('Title') @@ -1119,7 +1186,36 @@ def ties(lines): ]), [('C5', 0.0, 'start'), ('C5', 1.0, 'stop'), ('E5', 0.0, 'start'), ('E5', 1.0, 'stop')]) - def testSlursStayInTheirVoice(self): + def testTiesFollowTheirPitches(self): + def ties(lines): + return [(float(n.getOffsetInHierarchy(s)), n.tie.type if n.tie else None) + for s in [self.parseTreble(lines)] + for n in s.recurse().notes] + + self.assertEqual(ties([ + '|Note|Dur:4th|Pos:-6^', + '|Clef|Type:Bass', + '|Note|Dur:4th|Pos:6', + ]), [(0.0, 'start'), (1.0, 'stop')]) + self.assertEqual(ties([ + '|TimeSig|Signature:4/4', + '|Note|Dur:Whole|Pos:#1^', + '|Bar|', + '|Note|Dur:Whole|Pos:1', + ]), [(0.0, 'start'), (4.0, 'stop')]) + self.assertEqual(ties([ + '|Note|Dur:4th|Pos:1^', + '|Note|Dur:4th|Pos:2', + '|Note|Dur:4th|Pos:1', + ]), [(0.0, 'start'), (1.0, None), (2.0, None)]) + self.assertEqual(ties([ + '|Note|Dur:4th|Pos:1^', + '|AddStaff|', + '|Clef|Type:Treble', + '|Note|Dur:4th|Pos:1', + ]), [(0.0, 'start'), (0.0, None)]) + + def testSlursRunToTheNextNote(self): def slurs(lines): return [[repr(e) for e in sl.getSpannedElements()] for sl in self.parseTreble(lines).recurse().getElementsByClass(spanner.Slur)] @@ -1132,6 +1228,47 @@ def slurs(lines): '|Chord|Dur:4th,Slur|Pos:1|Dur2:4th|Pos2:3', '|Chord|Dur:4th|Pos:1|Dur2:4th|Pos2:3', ]), [['', '']]) + self.assertEqual(slurs([ + '|Chord|Dur:Half|Pos:1|Dur2:4th|Pos2:3', + '|Note|Dur:4th,Slur|Pos:2', + '|Note|Dur:4th|Pos:4', + ]), [['', '']]) + self.assertEqual(slurs([ + '|Chord|Dur:Half|Pos:1|Dur2:4th|Pos2:3', + '|Note|Dur:4th,Slur|Pos:2', + '|Bar|', + '|Note|Dur:4th|Pos:4', + ]), [['', '']]) + self.assertEqual(slurs([ + '|Chord|Dur:4th|Pos:1|Dur2:4th,Slur|Pos2:3', + '|Bar|', + '|Note|Dur:Whole|Pos:3', + '|Bar|', + '|Note|Dur:Whole|Pos:3', + '|Bar|', + '|Chord|Dur:4th|Pos:1|Dur2:4th|Pos2:3', + ]), [['', '']]) + self.assertEqual(slurs([ + '|Note|Dur:4th,Slur|Pos:1', + '|AddStaff|', + '|Clef|Type:Treble', + '|Note|Dur:4th|Pos:1', + ]), []) + + def testDirectionsGoAtThePosition(self): + for line, className in ( + ('|Dynamic|Style:p|Pos:-8', dynamics.Dynamic), + ('|Clef|Type:Bass', clef.BassClef), + ('|Key|Signature:F#', key.KeySignature), + ('|Text|Text:"dolce"|Pos:8', expressions.TextExpression)): + with self.subTest(line=line): + s = self.parseTreble([ + '|Chord|Dur:4th|Pos:-1|Dur2:Whole|Pos2:3', + line, + '|Note|Dur:4th|Pos:1', + ]) + self.assertEqual(s.recurse().getElementsByClass(className).first().offset, 1.0) + self.assertEqual(s.recurse().getElementsByClass(note.Note).first().offset, 1.0) def testRestChordFromBinary(self): import struct From f833ae14b0544bee276f013436b69fb3fe02c1f0 Mon Sep 17 00:00:00 2001 From: hill Date: Sun, 1 Nov 2026 02:53:37 +0100 Subject: [PATCH 5/7] Type NoteworthyTranslator.__init__, update NWC docs and to-do list setDurationForObject no longer reads slurs, so its example Dur info drops Slur. Dur2/Pos2 are read now, so the to-do entry for them goes. Annotating __init__ makes mypy check it, so currentMeasure starts as an empty Measure and the list and dict attributes get types. Test notes move into docstrings. --- music21/noteworthy/translate.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/music21/noteworthy/translate.py b/music21/noteworthy/translate.py index e384820415..056806e4ff 100644 --- a/music21/noteworthy/translate.py +++ b/music21/noteworthy/translate.py @@ -53,9 +53,6 @@ # low priority: # |MPC|Controller:vol|Style:Linear Sweep|TimeRes:Whole|SweepRes:1| # Pt1:0,127|Pt2:8,30|Pos:8|Wide:Y|Placement:BestFitForward -# -# Pos2? Dur2? -# |Chord|Dur:8th|Pos:-4,n-3,b-2,#-1,x0,v1,2x|Opts:Stem=Down,Crescendo|Dur2:8th,DblDotted|Pos2:3x from __future__ import annotations import re @@ -101,12 +98,12 @@ class NoteworthyTranslator: stores all the data about the current parse context (whether we're in a slur, tuplet, etc.) ''' - def __init__(self): - self.currentPart = None - self.currentMeasure = None + def __init__(self) -> None: + self.currentPart: stream.Part|None = None + self.currentMeasure: stream.Measure = stream.Measure() self.measureNumber = 0 self.currentEnding = 1 - self.repeatedMeasures = [] + self.repeatedMeasures: list[stream.Measure] = [] self.score = stream.Score() self.currentClef = 'TREBLE' @@ -118,9 +115,9 @@ def __init__(self): self.measurePosition: OffsetQL = 0.0 self.lyricPosition = 0 - self.lyrics = [] + self.lyrics: list[str] = [] - self.activeAccidentals = {} + self.activeAccidentals: dict[str, str] = {} def parseFile(self, filePath): try: @@ -243,7 +240,7 @@ def setDurationForObject(self, generalNote, durationInfo): DurationInfo is a string like: - Whole,Dotted,Slur + Whole,Dotted >>> nwt = noteworthy.translate.NoteworthyTranslator() >>> n = note.Note() @@ -1150,7 +1147,6 @@ def testNotesAfterGroupsFollowThePosition(self): (1.0, 2.0, 'B4'), (3.0, 1.0, 'C5'), ]) - # notes moved into the first voice keep their offsets self.assertEqual(self.firstMeasureContents([ '|Note|Dur:4th,Triplet|Pos:0', '|Note|Dur:4th,Triplet|Pos:1', @@ -1271,11 +1267,13 @@ def testDirectionsGoAtThePosition(self): self.assertEqual(s.recurse().getElementsByClass(note.Note).first().offset, 1.0) def testRestChordFromBinary(self): + ''' + Reads a rest chord from a version 1.75 file: object type 18, + visibility, ten data bytes (the rest's duration first, the number of + notes ninth), then a quarter note at staff position 0. + ''' import struct from music21.noteworthy import binaryTranslate - # a rest chord in a version 1.75 file: object type 18, visibility, ten - # data bytes (the rest's duration first, the number of notes ninth), - # then a quarter note at staff position 0 for restDuration, dumped, restLength in ( (1, '|RestChord|Dur:Half|Dur2:4th|Pos2:0', 2.0), (2, '|RestChord|Dur:4th|Dur2:4th|Pos2:0', 1.0)): From 62b51f54cdc6fe248c9d1a40004f231d34b040e4 Mon Sep 17 00:00:00 2001 From: hill Date: Sun, 1 Nov 2026 02:57:29 +0100 Subject: [PATCH 6/7] Stop an NWC tie only on the note right after it, across bar lines An open tie that ends at a bar line carries over to beat 1 of the next measure; at each bar line every other open tie is dropped. Before, Pos:1^ (whole), |Bar|, Pos:2, |Bar|, Pos:1 put a tie stop on the last note. --- music21/noteworthy/translate.py | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/music21/noteworthy/translate.py b/music21/noteworthy/translate.py index 056806e4ff..8464cfb560 100644 --- a/music21/noteworthy/translate.py +++ b/music21/noteworthy/translate.py @@ -110,7 +110,7 @@ def __init__(self) -> None: self.currentKey = key.KeySignature(0) self.openSlurs: list[tuple[note.GeneralNote, stream.Measure, OffsetQL]] = [] - self.tiedSteps: dict[tuple[str, int], tuple[stream.Measure, OffsetQL]] = {} + self.tiedSteps: dict[tuple[str, int], tuple[stream.Measure|None, OffsetQL]] = {} self.positionMeasure: stream.Measure|None = None self.measurePosition: OffsetQL = 0.0 @@ -294,10 +294,8 @@ def setTieFromPitchInfo(self, noteOrChord, pitchInfo): tiedFrom = False if step in self.tiedSteps: tiedMeasure, tiedEnd = self.tiedSteps.pop(step) - if tiedMeasure is self.currentMeasure: - tiedFrom = (tiedEnd == offset) - else: - tiedFrom = (offset == 0) + tiedFrom = ((tiedMeasure is None or tiedMeasure is self.currentMeasure) + and tiedEnd == offset) if tiedFrom and tiedOn: n.tie = tie.Tie('continue') elif tiedOn: @@ -851,7 +849,9 @@ def createPart(self): def createBarlines(self, attributes): r''' - Translates bar lines into music21. + Translates bar lines into music21. Open ties that end at the bar + line carry over to the start of the next measure; other open ties + are dropped. >>> nwt = noteworthy.translate.NoteworthyTranslator() >>> nwt.currentPart = stream.Part() @@ -863,6 +863,10 @@ def createBarlines(self, attributes): ''' self.activeAccidentals = {} + measureEnd = self.currentMeasure.highestTime + self.tiedSteps = {step: (None, 0.0) + for step, (tiedMeasure, tiedEnd) in self.tiedSteps.items() + if tiedMeasure is self.currentMeasure and tiedEnd == measureEnd} if 'Style' not in attributes: # pure barline @@ -1204,6 +1208,23 @@ def ties(lines): '|Note|Dur:4th|Pos:2', '|Note|Dur:4th|Pos:1', ]), [(0.0, 'start'), (1.0, None), (2.0, None)]) + self.assertEqual(ties([ + '|Note|Dur:Whole|Pos:1^', + '|Bar|', + '|Note|Dur:Whole|Pos:1', + ]), [(0.0, 'start'), (4.0, 'stop')]) + self.assertEqual(ties([ + '|Note|Dur:Whole|Pos:1^', + '|Bar|', + '|Note|Dur:Whole|Pos:2', + '|Bar|', + '|Note|Dur:Whole|Pos:1', + ]), [(0.0, 'start'), (4.0, None), (8.0, None)]) + self.assertEqual(ties([ + '|Chord|Dur:4th|Pos:1^|Dur2:Half|Pos2:3', + '|Bar|', + '|Note|Dur:4th|Pos:1', + ]), [(0.0, 'start'), (0.0, None), (2.0, None)]) self.assertEqual(ties([ '|Note|Dur:4th|Pos:1^', '|AddStaff|', From 02583b384b3ffc19d4feba4c89d80254dbe71afb Mon Sep 17 00:00:00 2001 From: hill Date: Sun, 1 Nov 2026 18:42:33 +0100 Subject: [PATCH 7/7] Bump to 11.0.0b16: NWC chords and rest chords read differently --- music21/_version.py | 2 +- music21/base.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/music21/_version.py b/music21/_version.py index cb79fa5eeb..e37cc4976a 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -47,7 +47,7 @@ ''' from __future__ import annotations -__version__ = '11.0.0b15' +__version__ = '11.0.0b16' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/base.py b/music21/base.py index e6254a1e20..b4a4bcd876 100644 --- a/music21/base.py +++ b/music21/base.py @@ -26,7 +26,7 @@ >>> music21.VERSION_STR -'11.0.0b15' +'11.0.0b16' Alternatively, after doing a complete import, these classes are available under the module "base":