diff --git a/music21/instrument.py b/music21/instrument.py index b368cb1b6..ecefc2216 100644 --- a/music21/instrument.py +++ b/music21/instrument.py @@ -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 # ------------------------------------------------------------------------------ ''' @@ -62,9 +62,9 @@ def unbundleInstruments(streamIn: stream.Stream, >>> s2 = instrument.unbundleInstruments(s) >>> s2.show('text') {0.0} - {0.0} + {0.0} {1.0} - {1.0} + {1.0} ''' if inPlace is True: s = streamIn @@ -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() @@ -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] @@ -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: diff --git a/music21/midi/translate.py b/music21/midi/translate.py index d37dd6590..12e0461ec 100644 --- a/music21/midi/translate.py +++ b/music21/midi/translate.py @@ -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 # ------------------------------------------------------------------------------ ''' @@ -369,7 +369,7 @@ def midiEventsToNote( >>> me1.channel = 10 >>> unp = midi.translate.midiEventsToNote(((dt1.time, me1), (dt2.time, me2))) >>> unp - + Access the `storedInstrument`: diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py index bcc3848b8..d6321e801 100644 --- a/music21/musicxml/m21ToXml.py +++ b/music21/musicxml/m21ToXml.py @@ -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 @@ -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' @@ -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 @@ -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 @@ -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: diff --git a/music21/note.py b/music21/note.py index a1076b223..3df3dc047 100644 --- a/music21/note.py +++ b/music21/note.py @@ -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. @@ -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' @@ -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 # ============================================================================================== @@ -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 + + >>> snare + + ''' + 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, @@ -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 + + + 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, @@ -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: ''' diff --git a/music21/percussion.py b/music21/percussion.py index b1198d0e9..7093032b0 100644 --- a/music21/percussion.py +++ b/music21/percussion.py @@ -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 - (, ) + (, ) Assign them to another PercussionChord: >>> pChord2 = percussion.PercussionChord() >>> pChord2.notes = pChord.notes >>> pChord2.notes - (, ) + (, ) Don't attempt setting anything but Note or Unpitched objects as notes: diff --git a/music21/stream/base.py b/music21/stream/base.py index 8e03fbe21..4dc799a1b 100644 --- a/music21/stream/base.py +++ b/music21/stream/base.py @@ -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 @@ -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 @@ -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#') @@ -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')