Skip to content

Commit

Permalink
Merge pull request #1682 from cuthbertLab/unpitched-improvements
Browse files Browse the repository at this point in the history
Unpitched/Percussion export improvements
  • Loading branch information
mscuthbert authored Jan 3, 2024
2 parents 815821a + fc02253 commit f1dab2f
Show file tree
Hide file tree
Showing 6 changed files with 112 additions and 75 deletions.
59 changes: 32 additions & 27 deletions music21/instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
# Ben Houge
# Mark Gotham
#
# Copyright: Copyright © 2009-2023 Michael Scott Asato Cuthbert
# Copyright: Copyright © 2009-2024 Michael Scott Asato Cuthbert
# License: BSD, see license.txt
# ------------------------------------------------------------------------------
'''
Expand Down Expand Up @@ -62,9 +62,9 @@ def unbundleInstruments(streamIn: stream.Stream,
>>> s2 = instrument.unbundleInstruments(s)
>>> s2.show('text')
{0.0} <music21.instrument.BassDrum 'Bass Drum'>
{0.0} <music21.note.Unpitched object at 0x...>
{0.0} <music21.note.Unpitched 'Bass Drum'>
{1.0} <music21.instrument.Cowbell 'Cowbell'>
{1.0} <music21.note.Unpitched object at 0x...>
{1.0} <music21.note.Unpitched 'Cowbell'>
'''
if inPlace is True:
s = streamIn
Expand Down Expand Up @@ -237,25 +237,16 @@ def instrumentIdRandomize(self):
self.instrumentId = idNew
self._instrumentIdIsRandom = True

# the empty list as default is actually CORRECT!
# noinspection PyDefaultArgument

def autoAssignMidiChannel(self, usedChannels=[]): # pylint: disable=dangerous-default-value
def autoAssignMidiChannel(self, usedChannels: list[int], maxMidi=16):
'''
Assign an unused midi channel given a list of
used channels.
used channels. Music21 uses 0-indexed MIDI channels.
assigns the number to self.midiChannel and returns
it as an int.
Note that midi channel 10 (9 in music21) is special, and
thus is skipped.
Currently only 16 channels are used.
Note that the reused "usedChannels=[]" in the
signature is NOT a mistake, but necessary for
the case where there needs to be a global list.
Note that the Percussion MIDI channel (9 in music21, 10 in 1-16 numbering) is special,
and thus is skipped.
>>> used = [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11]
>>> i = instrument.Violin()
Expand All @@ -264,6 +255,13 @@ def autoAssignMidiChannel(self, usedChannels=[]): # pylint: disable=dangerous-d
>>> i.midiChannel
12
Note that used is unchanged after calling this and would need to be updated manually
>>> used
[0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11]
Unpitched percussion will be set to 9, so long as it's not in the filter list:
>>> used = [0]
Expand All @@ -280,29 +278,36 @@ def autoAssignMidiChannel(self, usedChannels=[]): # pylint: disable=dangerous-d
>>> i.midiChannel
11
OMIT_FROM_DOCS
If all 16 channels are used, an exception is raised:
>>> used2 = range(16)
>>> i = instrument.Instrument()
>>> i.autoAssignMidiChannel(used2)
Traceback (most recent call last):
music21.exceptions21.InstrumentException: we are out of midi channels! help!
Get around this by assinging higher channels:
>>> i.autoAssignMidiChannel(used2, maxMidi=32)
16
>>> i.midiChannel
16
* Changed in v.9 -- usedChannelList is required, add maxMidi as an optional parameter.
various small tweaks for corner cases.
'''
# NOTE: this is used in musicxml output, not in midi output
maxMidi = 16
channelFilter = []
for e in usedChannels:
if e is not None:
channelFilter.append(e)
channelFilter = frozenset(usedChannels)

if not channelFilter:
if 'UnpitchedPercussion' in self.classes and 9 not in channelFilter:
self.midiChannel = 9
return self.midiChannel
elif not channelFilter:
self.midiChannel = 0
return self.midiChannel
elif len(channelFilter) >= maxMidi:
elif len(channelFilter) >= maxMidi - 1:
# subtract one, since we are not using percussion channel (=9)
raise InstrumentException('we are out of midi channels! help!')
elif 'UnpitchedPercussion' in self.classes and 9 not in usedChannels:
self.midiChannel = 9
return self.midiChannel
else:
for ch in range(maxMidi):
if ch in channelFilter:
Expand Down
4 changes: 2 additions & 2 deletions music21/midi/translate.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# Authors: Christopher Ariza
# Michael Scott Asato Cuthbert
#
# Copyright: Copyright © 2010-2023 Michael Scott Asato Cuthbert
# Copyright: Copyright © 2010-2024 Michael Scott Asato Cuthbert
# License: BSD, see license.txt
# ------------------------------------------------------------------------------
'''
Expand Down Expand Up @@ -369,7 +369,7 @@ def midiEventsToNote(
>>> me1.channel = 10
>>> unp = midi.translate.midiEventsToNote(((dt1.time, me1), (dt2.time, me2)))
>>> unp
<music21.note.Unpitched object at 0x...>
<music21.note.Unpitched 'Tom-Tom'>
Access the `storedInstrument`:
Expand Down
16 changes: 10 additions & 6 deletions music21/musicxml/m21ToXml.py
Original file line number Diff line number Diff line change
Expand Up @@ -3012,8 +3012,11 @@ def instrumentToXmlMidiInstrument(self, i):
mxMidiInstrument = Element('midi-instrument')
mxMidiInstrument.set('id', str(i.instrumentId))
if i.midiChannel is None:
i.autoAssignMidiChannel()
# TODO: allocate channels from a higher level
try:
i.autoAssignMidiChannel(self.midiChannelList)
except exceptions21.InstrumentException:
# warning will bubble up.
i.midiChannel = 0
mxMidiChannel = SubElement(mxMidiInstrument, 'midi-channel')
mxMidiChannel.text = str(i.midiChannel + 1)
# TODO: midi-name
Expand Down Expand Up @@ -3902,7 +3905,6 @@ def noteToXml(self, n: note.GeneralNote, noteIndexInChord=0, chordParent=None):
>>> len(MEX.xmlRoot)
1
>>> r = note.Rest()
>>> r.quarterLength = 1/3
>>> r.duration.tuplets[0].type = 'start'
Expand Down Expand Up @@ -3954,7 +3956,6 @@ def noteToXml(self, n: note.GeneralNote, noteIndexInChord=0, chordParent=None):
Try exporting with makeNotation=True or manually running splitAtDurations()
TODO: Test with spanners...
'''
addChordTag = (noteIndexInChord != 0)
setb = setAttributeFromAttribute
Expand Down Expand Up @@ -4171,7 +4172,10 @@ def setNoteInstrument(self,
return

searchingObject: note.NotRest|chord.Chord = chordParent if chordParent else n
closest_inst = searchingObject.getInstrument(returnDefault=True)
closest_inst_or_none = searchingObject.getInstrument()
if closest_inst_or_none is None:
return # no instrument, so no need to add anything
closest_inst: instrument.Instrument = closest_inst_or_none

instance_to_use = None
inst: instrument.Instrument
Expand All @@ -4183,7 +4187,7 @@ def setNoteInstrument(self,
if instance_to_use is None:
# exempt coverage, because this is only for safety/unreachable
raise MusicXMLExportException(
f'Could not find instrument instance for note {n} in instrumentStream'
f'Instrument instance {closest_inst} for note {n} not found in instrumentStream'
) # pragma: no cover
mxInstrument = SubElement(mxNote, 'instrument')
if instance_to_use.instrumentId is not None:
Expand Down
81 changes: 59 additions & 22 deletions music21/note.py
Original file line number Diff line number Diff line change
Expand Up @@ -996,6 +996,14 @@ class NotRest(GeneralNote):
Basically, that's a :class:`Note` or :class:`~music21.chord.Chord`
(or their subclasses such as :class:`~music21.harmony.ChordSymbol`), or
:class:`Unpitched` object.
NotRest elements are generally not created on their own. It is a class
that exists to store common functionality used by Note, Unpitched, and Chord objects.
>>> nr = note.NotRest(storedInstrument=instrument.Ocarina())
>>> nr.stemDirection = 'up'
* Changed in v9: beams is keyword only. Added storedInstrument keyword.
'''
# unspecified means that there may be a stem, but its orientation
# has not been declared.
Expand All @@ -1012,7 +1020,9 @@ class NotRest(GeneralNote):
)

def __init__(self,
*,
beams: beam.Beams|None = None,
storedInstrument: instrument.Instrument|None = None,
**keywords):
super().__init__(**keywords)
self._notehead: str = 'normal'
Expand All @@ -1024,7 +1034,7 @@ def __init__(self,
self.beams = beams
else:
self.beams = beam.Beams()
self._storedInstrument: instrument.Instrument|None = None
self._storedInstrument: instrument.Instrument|None = storedInstrument
self._chordAttached: chord.ChordBase|None = None

# ==============================================================================================
Expand Down Expand Up @@ -1297,24 +1307,32 @@ def volume(self) -> volume.Volume:
def volume(self, value: None|volume.Volume|int|float):
self._setVolume(value)

def _getStoredInstrument(self):
return self._storedInstrument

def _setStoredInstrument(self, newValue):
if not (hasattr(newValue, 'instrumentId') or newValue is None):
raise TypeError(f'Expected Instrument; got {type(newValue)}')
self._storedInstrument = newValue

storedInstrument = property(_getStoredInstrument,
_setStoredInstrument,
doc='''
Get and set the :class:`~music21.instrument.Instrument` that
@property
def storedInstrument(self) -> instrument.Instrument|None:
'''
Get or set the :class:`~music21.instrument.Instrument` that
should be used to play this note, overriding whatever
Instrument object may be active in the Stream. (See
:meth:`getInstrument` for a means of retrieving `storedInstrument`
if available before falling back to a context search to find
the active instrument.)
''')
>>> snare = note.Unpitched()
>>> snare.storedInstrument = instrument.SnareDrum()
>>> snare.storedInstrument
<music21.instrument.SnareDrum 'Snare Drum'>
>>> snare
<music21.note.Unpitched 'Snare Drum'>
'''
return self._storedInstrument

@storedInstrument.setter
def storedInstrument(self, newValue: instrument.Instrument|None):
if (newValue is not None
and (not hasattr(newValue, 'classSet')
or 'music21.instrument.Instrument' not in newValue.classSet)):
raise TypeError(f'Expected Instrument; got {type(newValue)}')
self._storedInstrument = newValue

@overload
def getInstrument(self,
Expand Down Expand Up @@ -1831,11 +1849,32 @@ class Unpitched(NotRest):
>>> unp.pitch
Traceback (most recent call last):
AttributeError: 'Unpitched' object has no attribute 'pitch...
Unpitched elements generally have an instrument object associated with them:
>>> unp.storedInstrument = instrument.Woodblock()
>>> unp
<music21.note.Unpitched 'Woodblock'>
Two unpitched objects compare the same if their instrument and displayStep and
displayOctave are equal (and satisfy all the equality requirements of
their base classes):
>>> unp2 = note.Unpitched()
>>> unp == unp2
False
>>> unp2.displayStep = 'G'
>>> unp2.storedInstrument = instrument.Woodblock()
>>> unp == unp2
True
>>> unp2.storedInstrument = instrument.Triangle()
>>> unp == unp2
False
'''
# TODO: when Python 3.12 is minimum version. Change AttributeError to read:
# AttributeError: 'Unpitched' object has no attribute 'pitch'. Did you mean: 'pitches'?

equalityAttributes = ('displayStep', 'displayOctave')
equalityAttributes = ('displayStep', 'displayOctave', 'storedInstrument')

def __init__(
self,
Expand All @@ -1852,13 +1891,11 @@ def __init__(
self.displayStep = display_pitch.step
self.displayOctave = display_pitch.implicitOctave

def _getStoredInstrument(self):
return self._storedInstrument

def _setStoredInstrument(self, newValue):
self._storedInstrument = newValue

storedInstrument = property(_getStoredInstrument, _setStoredInstrument)
def _reprInternal(self):
if not self.storedInstrument:
return ''
else:
return repr(self.storedInstrument.instrumentName)

def displayPitch(self) -> Pitch:
'''
Expand Down
7 changes: 4 additions & 3 deletions music21/percussion.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,22 @@ class PercussionChord(chord.ChordBase):
a :class:`~music21.chord.Chord` because one or more notes is an :class:`~music21.note.Unpitched`
object.
>>> pChord = percussion.PercussionChord([note.Unpitched(displayName='D4'), note.Note('E5')])
>>> vibraslapNote = note.Unpitched(displayName='D4', storedInstrument=instrument.Vibraslap())
>>> pChord = percussion.PercussionChord([vibraslapNote, note.Note('E5')])
>>> pChord.isChord
False
Has notes, just like any ChordBase:
>>> pChord.notes
(<music21.note.Unpitched object at 0x...>, <music21.note.Note E>)
(<music21.note.Unpitched 'Vibraslap'>, <music21.note.Note E>)
Assign them to another PercussionChord:
>>> pChord2 = percussion.PercussionChord()
>>> pChord2.notes = pChord.notes
>>> pChord2.notes
(<music21.note.Unpitched object at 0x...>, <music21.note.Note E>)
(<music21.note.Unpitched 'Vibraslap'>, <music21.note.Note E>)
Don't attempt setting anything but Note or Unpitched objects as notes:
Expand Down
20 changes: 5 additions & 15 deletions music21/stream/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1437,22 +1437,14 @@ def mergeAttributes(self, other: base.Music21Object):
if hasattr(other, attr):
setattr(self, attr, getattr(other, attr))

@common.deprecated('v10', 'v11', 'Use `el in stream` instead of '
@common.deprecated('v9.3', 'v11', 'Use `el in stream` instead of '
'`stream.hasElement(el)`')
def hasElement(self, obj: base.Music21Object) -> bool:
'''
DEPRECATED: just use `el in stream` instead of `stream.hasElement(el)`

Return True if an element, provided as an argument, is contained in
this Stream.

This method is based on object equivalence, not parameter equivalence
of different objects.

>>> s = stream.Stream()
>>> n1 = note.Note('g')
>>> n2 = note.Note('g#')
>>> s.append(n1)
>>> s.hasElement(n1)
True
'''
return obj in self

Expand All @@ -1473,7 +1465,7 @@ def hasElementOfClass(self, className, forceFlat=False):
>>> s.hasElementOfClass('Measure')
False

To be deprecated in v8 -- to be removed in v9, use:
To be deprecated in v10 -- to be removed in v11, use:

>>> bool(s.getElementsByClass(meter.TimeSignature))
True
Expand All @@ -1500,7 +1492,6 @@ def mergeElements(self, other, classFilterList=None):
but manages locations properly, only copies elements,
and permits filtering by class type.


>>> s1 = stream.Stream()
>>> s2 = stream.Stream()
>>> n1 = note.Note('f#')
Expand Down Expand Up @@ -1567,8 +1558,7 @@ def index(self, el: base.Music21Object) -> int:
Return the first matched index for
the specified object.

Raises a StreamException if the object cannot
be found.
Raises a StreamException if the object cannot be found.

>>> s = stream.Stream()
>>> n1 = note.Note('G')
Expand Down

0 comments on commit f1dab2f

Please sign in to comment.