Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New .sequence attribute on Voice to distinguish from .id and control display #915

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion music21/analysis/reduction.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,9 +372,10 @@ def _createReduction(self):
if not gMeasure.voices: # common setup routines
# if no voices, start by removing rests
gMeasure.removeByClass('Rest')
for vId in self._reductiveVoices:
for i, vId in enumerate(self._reductiveVoices):
v = stream.Voice()
v.id = vId
v.sequence = i + 1 # 1-indexed
gMeasure.insert(0, v)
if oneVoice:
n, te = rn.getNoteAndTextExpression()
Expand Down
2 changes: 1 addition & 1 deletion music21/humdrum/spineParser.py
Original file line number Diff line number Diff line change
Expand Up @@ -2016,7 +2016,7 @@ def makeVoices(self):
voiceNumber = int(voiceName[5])
voicePart = voices[voiceNumber]
if voicePart is None:
voices[voiceNumber] = stream.Voice()
voices[voiceNumber] = stream.Voice(id=voiceNumber, sequence=voiceNumber)
voicePart = voices[voiceNumber]
voicePart.groups.append(voiceName)
mElOffset = mEl.offset
Expand Down
41 changes: 25 additions & 16 deletions music21/musicxml/m21ToXml.py
Original file line number Diff line number Diff line change
Expand Up @@ -2829,7 +2829,7 @@ def __init__(self, measureObj=None, parent=None):
self.measureOffsetStart = 0.0
self.offsetInMeasure = 0.0
self.currentVoiceId: Optional[int] = None
self.nextFreeVoiceNumber = 1
# self.nextFreeVoiceNumber = 1

self.rbSpanners = [] # repeatBracket spanners

Expand Down Expand Up @@ -2902,16 +2902,11 @@ def parseFlatElements(self, m, *, backupAfterwards=False):
self.offsetInMeasure = 0.0
if isinstance(m, stream.Voice):
m: stream.Voice
if isinstance(m.id, int) and m.id < defaults.minIdNumberToConsiderMemoryLocation:
voiceId = m.id
elif isinstance(m.id, int):
voiceId = self.nextFreeVoiceNumber
self.nextFreeVoiceNumber += 1
else:
voiceId = m.id
voiceId = m.sequence
else:
voiceId = None

# TODO: rename, since this now reads from .sequence
self.currentVoiceId = voiceId

# group all objects by offsets and then do a different order than normal sort.
Expand Down Expand Up @@ -6471,26 +6466,40 @@ def stripInnerSpaces(txt):
stripInnerSpaces(xmlAfterFirstBackup)
)

def testLowVoiceNumbers(self):
def testVoiceNumbers(self):
n = note.Note()
v1 = stream.Voice([n])
m = stream.Measure([v1])
v1 = stream.Voice(n)
m = stream.Measure(v1)

# Unnecessary voice is removed by makeNotation
xmlOut = self.getXml(m)
self.assertNotIn('<voice>1</voice>', xmlOut)
n2 = note.Note()
v2 = stream.Voice([n2])
v2 = stream.Voice(n2)
m.insert(0, v2)
xmlOut = self.getXml(m)
self.assertIn('<voice>1</voice>', xmlOut)
self.assertIn('<voice>2</voice>', xmlOut)
v1.id = 234
v1.sequence = 234
# Number set by user will not be written, because makeNotation renumbers
xmlOut = self.getXml(m)
self.assertIn('<voice>234</voice>', xmlOut)
self.assertIn('<voice>1</voice>', xmlOut) # is v2 now!
self.assertNotIn('<voice>234</voice>', xmlOut)
self.assertIn('<voice>1</voice>', xmlOut)
self.assertIn('<voice>2</voice>', xmlOut)

# Sequence set by user WILL be written, because makeNotation=False
# TODO: implement makeNotation=False
# newScore = stream.Score([stream.Part()])
# newScore.parts.first().append(m)
# root = self.getET(newScore)
# xmlOut = helpers.dumpString(root)
# self.assertIn('<voice>234</voice>', xmlOut)
# self.assertIn('<voice>-1</voice>', xmlOut) # this voice was never given a .sequence

# Other fields such as .id will not be written to <voice>
v2.id = 'hello'
xmlOut = self.getXml(m)
self.assertIn('<voice>hello</voice>', xmlOut)
self.assertNotIn('<voice>hello</voice>', xmlOut)

def testCompositeLyrics(self):
from music21 import converter
Expand Down
13 changes: 9 additions & 4 deletions music21/musicxml/xmlToM21.py
Original file line number Diff line number Diff line change
Expand Up @@ -1749,6 +1749,7 @@ def copy_into_partStaff(source, target, omitTheseElementIds):
):
copy_into_partStaff(sourceMeasure, copyMeasure, elementsIdsNotToGoInThisStaff)
for sourceVoice, copyVoice in zip(sourceMeasure.voices, copyMeasure.voices):
copyVoice.sequence = sourceVoice.sequence
copy_into_partStaff(sourceVoice, copyVoice, elementsIdsNotToGoInThisStaff)
copyMeasure.flattenUnnecessaryVoices(force=False, inPlace=True)

Expand Down Expand Up @@ -5821,7 +5822,7 @@ def updateVoiceInformation(self):
>>> len(MP.stream)
2
>>> list(MP.stream.getElementsByClass('Voice'))
[<music21.stream.Voice 1>, <music21.stream.Voice 2>]
[<music21.stream.Voice 1 (seq:1)>, <music21.stream.Voice 2 (seq:2)>]
'''
mxm = self.mxMeasure
for mxn in mxm.findall('note'):
Expand All @@ -5833,9 +5834,12 @@ def updateVoiceInformation(self):
# additional time < 1 sec per ten million ops.

if len(self.voiceIndices) > 1:
for vIndex in sorted(self.voiceIndices):
vSequence: int = 0
for vId in sorted(self.voiceIndices):
vSequence += 1
v = stream.Voice()
v.id = vIndex # TODO: should use a separate voiceId or something in Voice.
v.id = vId # parsed from XML document
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one-line change could be its own PR discussed separately, maybe. Rather than "correct" whatever we get in the <voice> tags, just accept whatever we get.

v.sequence = vSequence # 1-indexed sequence in m21
self.stream.coreInsert(0.0, v)
self.voicesById[v.id] = v
self.useVoices = True
Expand Down Expand Up @@ -5904,6 +5908,7 @@ def testVoices(self):
self.assertTrue(m1.hasVoices())

self.assertEqual([v.id for v in m1.voices], ['1', '2'])
self.assertEqual([v.sequence for v in m1.voices], [1, 2])

self.assertEqual([e.offset for e in m1.voices[0]], [0.0, 1.0, 2.0, 3.0])
self.assertEqual([e.offset for e in m1.voices['1']], [0.0, 1.0, 2.0, 3.0])
Expand Down Expand Up @@ -6639,7 +6644,7 @@ def testTwoVoicesWithChords(self):
c = corpus.parse('demos/voices_with_chords.xml')
m1 = c.parts[0].measure(1)
# m1.show('text')
firstChord = m1.voices.getElementById('2').getElementsByClass('Chord').first()
firstChord = m1.voices.last().getElementsByClass('Chord').first()
self.assertEqual(repr(firstChord), '<music21.chord.Chord G4 B4>')
self.assertEqual(firstChord.offset, 1.0)

Expand Down
138 changes: 126 additions & 12 deletions music21/stream/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2893,9 +2893,9 @@ def splitAtDurations(self, *, recurse=False) -> base._SplitTuple:
`recurse=True` should not be necessary to find elements in streams
without substreams, such as a loose Voice:

>>> v = stream.Voice([note.Note(quarterLength=5.5)], id=1)
>>> v = stream.Voice([note.Note(quarterLength=5.5)], sequence=1)
>>> v.splitAtDurations()
(<music21.stream.Voice 1>,)
(<music21.stream.Voice (seq:1)>,)
>>> [n.duration for n in v.notes]
[<music21.duration.Duration 4.0>, <music21.duration.Duration 1.5>]

Expand Down Expand Up @@ -6397,9 +6397,15 @@ def offsetMap(self, srcObj=None):
consisting of the 'offset' of each element in a stream, the
'endTime' (that is, the offset plus the duration) and the
'element' itself. Also contains a 'voiceIndex' entry which
contains the voice number of the element, or None if there
contains the voice index of the element, or None if there
are no voices.

The `voiceIndex` is 0-indexed and recalculated each time this
method is called;
it doesn't read from :attr:`Voice.sequence`, which is set by file parsers
and by :meth:`flattenUnnecessaryVoices`, which is called during MusicXML
export.

>>> n1 = note.Note(type='quarter')
>>> c1 = clef.AltoClef()
>>> n2 = note.Note(type='half')
Expand Down Expand Up @@ -10883,8 +10889,11 @@ def makeVoices(self, *, inPlace=False, fillGaps=True):
# remove from source
returnObj.remove(e)
# remove any unused voices (possible if overlap group has sus)
voiceSequence: int = 0
for v in voices:
if v: # skip empty voices
voiceSequence += 1 # 1-indexed
v.sequence = voiceSequence
returnObj.insert(0, v)
if fillGaps:
returnObj.makeRests(fillGaps=True,
Expand Down Expand Up @@ -10977,7 +10986,7 @@ def _maxVoiceCount(self, *, countById=False):
if not countById:
voiceCount = len(voices)
else:
voiceIds = [v.id for v in voices]
voiceIds = [str(v.id) for v in voices]
else: # if no measure or voices, get one part
voiceCount = 1

Expand Down Expand Up @@ -11010,20 +11019,20 @@ def voicesToParts(self, *, separateById=False):
{0.0} <music21.clef.BassClef>
{0.0} <music21.key.Key of D major>
{0.0} <music21.meter.TimeSignature 4/4>
{0.0} <music21.stream.Voice 3>
{0.0} <music21.stream.Voice 3 (seq:1)>
{0.0} <music21.note.Note E>
...
{3.0} <music21.note.Rest quarter>
{0.0} <music21.stream.Voice 4>
{0.0} <music21.stream.Voice 4 (seq:2)>
{0.0} <music21.note.Note F#>
...
{3.5} <music21.note.Note B>
{4.0} <music21.stream.Measure 2 offset=4.0>
{0.0} <music21.stream.Voice 3>
{0.0} <music21.stream.Voice 3 (seq:1)>
{0.0} <music21.note.Note E>
...
{3.0} <music21.note.Rest quarter>
{0.0} <music21.stream.Voice 4>
{0.0} <music21.stream.Voice 4 (seq:2)>
{0.0} <music21.note.Note E>
...
{3.5} <music21.note.Note A>
Expand Down Expand Up @@ -11134,7 +11143,7 @@ def voicesToParts(self, *, separateById=False):
partDict[i] = p
else:
voiceId = voiceIds[i]
p.id = str(self.id) + '-' + voiceId
p.id = str(self.id) + '-' + str(voiceId)
partDict[voiceId] = p

def doOneMeasureWithVoices(mInner):
Expand Down Expand Up @@ -11239,7 +11248,7 @@ def explode(self):
'''
return self.voicesToParts()

def flattenUnnecessaryVoices(self, *, force=False, inPlace=False):
def flattenUnnecessaryVoices(self, *, force=False, inPlace=False, recurse=False):
'''
If this Stream defines one or more internal voices, do the following:

Expand All @@ -11249,8 +11258,13 @@ def flattenUnnecessaryVoices(self, *, force=False, inPlace=False):
elements in the parent Stream.
* If `force` is True, even if there is more than one Voice left,
all voices will be flattened.
* Renumber the voices beginning at 1,
if that voice's `.freezeSequence` is False (default).
* If `recurse` is True, process every inner stream. (For instance,
to enable calling this on the top-level score.)

Changed in v. 5 -- inPlace is default False and a keyword only arg.
Changed in v. 7 -- now renumbers voices and added `recurse`.

>>> s = stream.Stream(note.Note())
>>> s.insert(0, note.Note())
Expand All @@ -11264,15 +11278,49 @@ def flattenUnnecessaryVoices(self, *, force=False, inPlace=False):
>>> voicesFlattened = s.flattenUnnecessaryVoices()
>>> len(voicesFlattened.voices)
0

>>> s = stream.Stream()
>>> s.repeatInsert(note.Note(), [0, 0, 0]) # simultaneous
>>> s.makeVoices(inPlace=True)
>>> s.voices[0].sequence = -1
>>> s.voices[1].sequence = 5
>>> s.voices[2].sequence = 10
>>> s.voices[2].freezeSequence = True
>>> s.flattenUnnecessaryVoices(inPlace=True)
>>> [v for v in s.voices]
[<music21.stream.Voice (seq:1)>,
<music21.stream.Voice (seq:2)>,
<music21.stream.Voice (seq:10)>]

>>> m = stream.Measure()
>>> m.repeatInsert(note.Note(), [0, 0, 0]) # simultaneous
>>> m.makeVoices(inPlace=True)
>>> p = stream.Part([m])
>>> p.insert(0, stream.Voice()) # extra unnecessary voice
>>> s = stream.Score([p])
>>> post = s.flattenUnnecessaryVoices(force=True, recurse=True)
>>> post.show('text')
{0.0} <music21.stream.Part 0x...>
{0.0} <music21.stream.Measure 0 offset=0.0>
{0.0} <music21.note.Note C>
{0.0} <music21.note.Note C>
{0.0} <music21.note.Note C>
'''
if not self.voices:
if not recurse and not self.voices:
return None # do not make copy; return immediately

if not inPlace: # make a copy
returnObj = copy.deepcopy(self)
else:
returnObj = self

# Handle inner streams
if recurse:
for innerStream in list(returnObj.recurse(includeSelf=False, streamsOnly=True)):
if 'Voice' in innerStream.classes:
continue
innerStream.flattenUnnecessaryVoices(force=force, inPlace=True, recurse=False)

# collect voices for removal and for flattening
remove = []
flatten = []
Expand All @@ -11295,6 +11343,11 @@ def flattenUnnecessaryVoices(self, *, force=False, inPlace=False):
returnObj.remove(v)
returnObj.coreElementsChanged()

# Renumber voices (1-indexed)
for i, v in enumerate(returnObj.voices):
if not v.freezeSequence:
v.sequence = i + 1

if not inPlace:
return returnObj
else:
Expand Down Expand Up @@ -12632,13 +12685,74 @@ class Voice(Stream):
stream belongs to a certain "voice" for analysis or display
purposes.

:attr:`~music21.base.Music21Object.id` is used for analysis or association
of voices across multiple measures, whereas :attr:`~music21.stream.Voice.sequence`
is used to identify the intended display or ordering inside a single measure.

The `.id` attribute may also store voice numbers parsed from a file (which,
depending on the format, may or may not truly represent analytical connections).
This functionality allows for extracting analytical voices via
:meth:`~music21.stream.Stream.voicesToParts` using `separateById=True`.
By contrast, the `.sequence` attribute is a 1-indexed sequence that
will increment when voices are created by notation routines and
will be renumbered after transformations by :meth:`flattenUnnecessaryVoices`,
a helper method run by most :meth:`makeNotation` operations, including
during MusicXML export, in order to guarantee correct MusicXML display.

MusicXML allows any string for the `<voice>` tag, but in practice, MusicXML
producers begin at '1' and (should, but not always) continue the numbering
on the subsequent staff of a multi-staff part. Since music21 automatically
separates multi-staff parts on import, the :attr:`sequence` will reset to reflect
the actual count on each :class:`PartStaff` object. The original MusicXML
`<voice>` number can be found on :attr:`id`, but it may or may not be
the same `.sequence` number on export according to stream transformations
(or on account of PartStaff separation of keyboard and other multi-part staves
on import).

If default voice numbering produces unsatisfactory output, numbering can be
overridden by manipulating `.sequence` on the Voice objects and setting
:attr:`~music21.stream.Voice.freezeSequence` to True to avoid renumbering by
:meth:`flattenUnnecessaryVoices`.

See :meth:`voicesToParts`, especially the keyword `separateById`, for examples
of how to extract and associate voices together using :attr:`id` without relying on
the :attr:`sequence` attribute.

:meth:`offsetMap` recalculates a 0-indexed count "voiceIndex" of voices in a container
at any given time, since there is no constraint until the user exports MusicXML
or elects to run :meth:`flattenUnnecessaryVoices` that the :attr:`sequence`
attributes of each Voice form a valid sequence.

Note that both Finale's Layers and Voices as concepts are
considered Voices here.

Voices have a sort order of 1 greater than time signatures
Voices have a sort order of 1 greater than time signatures.

Changed in v.7 -- added attribute `.sequence`
'''
recursionType = 'elementsFirst'
classSortOrder = 5
_DOC_ORDER = ['']
_DOC_ATTR = {
'sequence': '''
int describing the 1-indexed sequence of this voice in its container
(most likely Measure). Renumbering performed by
:meth:`flattenUnnecessaryVoices`.''',
'freezeSequence': '''
bool describing whether the sequence attributes are free to be renumbered
by export or notation routines.''',
}

def __init__(self, *args, **keywords):
super().__init__(*args, **keywords)
self.sequence: int = int(keywords['sequence']) if 'sequence' in keywords else -1
self.freezeSequence: bool = False

def _reprInternal(self):
if isinstance(self.id, int) and self.id > defaults.minIdNumberToConsiderMemoryLocation:
return f'(seq:{self.sequence})'
else:
return f'{self.id} (seq:{self.sequence})'


# -----------------------------------------------------------------------------
Expand Down
Loading