Repository: cuthbertLab/music21
Pull request #1454 (2022-10-07 11:58:31)

Add utility for removing duplicated objects (e.g. keys, clefs)


Patch:
diff --git a/.pylintrc b/.pylintrc
index e888a88f8..c5b8cb153 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -378,4 +378,4 @@ int-import-graph=
 
 # Exceptions that will emit a warning when being caught. Defaults to
 # "Exception"
-overgeneral-exceptions=Exception
+# overgeneral-exceptions=Exception
diff --git a/documentation/source/developerReference/documenting.rst b/documentation/source/developerReference/documenting.rst
index cf8ea77e0..6f620fbd7 100644
--- a/documentation/source/developerReference/documenting.rst
+++ b/documentation/source/developerReference/documenting.rst
@@ -288,7 +288,7 @@ To document a property do something like this:
 
     def _setName(self, newName):
        if newName == 'Cuthbert':
-           raise Exception("what a dumb name!")
+           raise ValueError("what a dumb name!")
        else:
            self._storedName = newName
 
diff --git a/music21/_version.py b/music21/_version.py
index 8b418503b..bd2fce3cc 100644
--- a/music21/_version.py
+++ b/music21/_version.py
@@ -47,7 +47,7 @@ Changing this number invalidates old pickles -- do it if the old pickles create
 '''
 from __future__ import annotations
 
-__version__ = '9.0.0a6'
+__version__ = '9.0.0a9'
 
 
 def get_version_tuple(vv):
diff --git a/music21/articulations.py b/music21/articulations.py
index 65ce60801..fdb00874d 100644
--- a/music21/articulations.py
+++ b/music21/articulations.py
@@ -84,6 +84,7 @@ from music21 import common
 from music21.common.classTools import tempAttribute
 from music21 import environment
 from music21 import style
+from music21 import spanner
 
 if t.TYPE_CHECKING:
     from music21 import interval
@@ -568,10 +569,16 @@ class FrettedPluck(FretIndication, Fingering):
     '''
     pass
 
-class HammerOn(FretIndication):
+class HammerOn(spanner.Spanner, TechnicalIndication):
+    '''
+    A hammer-on represented as a spanner between two Notes.
+    '''
     pass
 
-class PullOff(FretIndication):
+class PullOff(spanner.Spanner, TechnicalIndication):
+    '''
+    A pull-off represented as a spanner between two Notes.
+    '''
     pass
 
 class FretBend(FretIndication):
@@ -681,4 +688,3 @@ _DOC_ORDER = [Articulation]
 if __name__ == '__main__':
     import music21
     music21.mainTest(Test)
-
diff --git a/music21/base.py b/music21/base.py
index a23bdeb6d..2235d1dd7 100644
--- a/music21/base.py
+++ b/music21/base.py
@@ -27,7 +27,7 @@ available after importing `music21`.
 <class 'music21.base.Music21Object'>
 
 >>> music21.VERSION_STR
-'9.0.0a6'
+'9.0.0a9'
 
 Alternatively, after doing a complete import, these classes are available
 under the module "base":
@@ -2768,7 +2768,7 @@ class Music21Object(prebase.ProtoM21Object):
 
         except AttributeError as ae:
             # need to permit Duration object assignment here
-            raise Exception(
+            raise TypeError(
                 f'this must be a Duration object, not {durationObj}'
             ) from ae
 
diff --git a/music21/chord/__init__.py b/music21/chord/__init__.py
index fa628a9e1..4c5aaa3a8 100644
--- a/music21/chord/__init__.py
+++ b/music21/chord/__init__.py
@@ -1767,6 +1767,11 @@ class Chord(ChordBase):
 
         stepNumsToPitches: dict[int, pitch.Pitch] = {pitch.STEP_TO_DNN_OFFSET[p.step]: p
                                                      for p in nonDuplicatingPitches}
+
+        # TODO: duplicate the steps array [1,0,1,0,1,0,0] so it's [1,0,1,0,1,0,0,1,0,1,0,1,0,0]
+        #    and then for each cardinality, use a template like [1, 0, 1, 0, 1] to slide along
+        #    and see if it fits -- this will allow this routine to work for any number of
+        #    steps from 3-6.
         stepNums = sorted(stepNumsToPitches)
         for startIndex in range(lenPitches):
             all_are_thirds = True
diff --git a/music21/common/classTools.py b/music21/common/classTools.py
index f2c4434be..04a9d5ca8 100644
--- a/music21/common/classTools.py
+++ b/music21/common/classTools.py
@@ -80,7 +80,7 @@ def isNum(usrData: t.Any) -> t.TypeGuard[numbers.Rational]:
             return True
         else:
             return False
-    except Exception:  # pylint: disable=broad-except
+    except Exception:  # pylint: disable=broad-exception-caught
         return False
 
 
diff --git a/music21/common/enums.py b/music21/common/enums.py
index cc074e129..ff2e8561b 100644
--- a/music21/common/enums.py
+++ b/music21/common/enums.py
@@ -215,6 +215,20 @@ class AppendSpanners(StrEnum):
     NONE = 'none'
 
 
+class OrnamentDelay(StrEnum):
+    '''
+    An enumeration for the delay in an ornament (e.g. a delayed turn).  The delay for an
+    ornament can be set to one of these values, or to an OffsetQL for a timed delay.
+
+    OrnamentDelay.NO_DELAY means there is no delay (this is equivalent to setting delay to 0.0)
+    OrnamentDelay.DEFAULT_DELAY means the delay is half the duration of the ornamented note.
+
+    * new in v9.
+    '''
+    NO_DELAY = 'noDelay'
+    DEFAULT_DELAY = 'defaultDelay'
+
+
 class MeterDivision(StrEnum):
     '''
     Represents an indication of how to divide a TimeSignature
diff --git a/music21/common/fileTools.py b/music21/common/fileTools.py
index 78089209b..43df44c52 100644
--- a/music21/common/fileTools.py
+++ b/music21/common/fileTools.py
@@ -63,7 +63,7 @@ def readPickleGzip(filePath: str | pathlib.Path) -> t.Any:
         try:
             uncompressed = pickledFile.read()
             newMdb = pickle.loads(uncompressed)
-        except Exception as e:  # pylint: disable=broad-except
+        except Exception as e:  # pylint: disable=broad-exception-caught
             # pickle exceptions cannot be caught directly
             # because they might come from pickle or _pickle and the latter cannot
             # be caught.
diff --git a/music21/common/numberTools.py b/music21/common/numberTools.py
index a6746dcf5..dc76d71d7 100644
--- a/music21/common/numberTools.py
+++ b/music21/common/numberTools.py
@@ -559,7 +559,7 @@ def nearestMultiple(n: float, unit: float) -> tuple[float, float, float]:
     # print(['mult, halfUnit, matchLow, matchHigh', mult, halfUnit, matchLow, matchHigh])
 
     if matchLow >= n >= matchHigh:
-        raise Exception(f'cannot place n between multiples: {matchLow}, {matchHigh}')
+        raise ValueError(f'cannot place n between multiples: {matchLow}, {matchHigh}')
 
     if matchLow <= n <= (matchLow + halfUnit):
         return matchLow, round(n - matchLow, 7), round(n - matchLow, 7)
@@ -645,7 +645,7 @@ def decimalToTuplet(decNum: float) -> tuple[int, int]:
     (jy, iy) = findSimpleFraction(working)
 
     if iy == 0:
-        raise Exception('No such luck')
+        raise ValueError('No such luck')
 
     jy *= multiplier
     my_gcd = gcd(int(jy), int(iy))
@@ -812,7 +812,7 @@ def approximateGCD(values: Collection[int | float | Fraction], grain: float = 1e
         if count == len(divisions):
             commonUniqueDivisions.append(v)
     if not commonUniqueDivisions:
-        raise Exception('cannot find a common divisor')
+        raise ValueError('cannot find a common divisor')
     return max(commonUniqueDivisions)
 
 
diff --git a/music21/common/pathTools.py b/music21/common/pathTools.py
index 576f87a52..d24e8d217 100644
--- a/music21/common/pathTools.py
+++ b/music21/common/pathTools.py
@@ -41,7 +41,7 @@ def getSourceFilePath() -> pathlib.Path:
     fpMusic21 = fpThis.parent.parent  # common is two levels deep
     # use stream as a test case
     if 'stream' not in [x.name for x in fpMusic21.iterdir()]:
-        raise Exception(
+        raise FileNotFoundError(
             f'cannot find expected music21 directory: {fpMusic21}'
         )  # pragma: no cover
     return fpMusic21
diff --git a/music21/configure.py b/music21/configure.py
index e3406063c..0ac04b27e 100644
--- a/music21/configure.py
+++ b/music21/configure.py
@@ -288,7 +288,7 @@ class Dialog:
         except KeyboardInterrupt:
             # store as own class so as a subclass of dialog error
             return KeyInterruptError()
-        except Exception:  # pylint: disable=broad-except
+        except Exception:  # pylint: disable=broad-exception-caught
             return DialogError()
 
     def prependPromptHeader(self, msg):
diff --git a/music21/corpus/chorales.py b/music21/corpus/chorales.py
index dc7fde176..a9ef81414 100644
--- a/music21/corpus/chorales.py
+++ b/music21/corpus/chorales.py
@@ -1278,7 +1278,7 @@ class Iterator:
                     analysis = corpus.parse(riemenschneiderName)
                     if analysis is not None:
                         chorale.insert(0, analysis.parts[0])
-                except Exception:  # pylint: disable=broad-except
+                except Exception:  # pylint: disable=broad-exception-caught
                     pass  # fail silently
             # Store the correct title in metadata (replacing the chorale number as it is parsed)
             if chorale.metadata is None:
@@ -1290,7 +1290,7 @@ class Iterator:
         elif self._returnType == 'filename':
             return filename
         else:
-            raise Exception(
+            raise ValueError(
                 f'An unexpected returnType {self._returnType} was introduced. '
                 + 'This should not happen.'
             )
@@ -1554,7 +1554,7 @@ class Iterator:
     @currentNumber.setter
     def currentNumber(self, value):
         if self._numberingSystem is None:
-            raise Exception('Numbering System is not set.')
+            raise ValueError('Numbering System is not set.')
         if self._iterationType == 'number':
             if self._numberingSystem == 'title':
                 if not self._titleList:
@@ -1639,7 +1639,7 @@ class Iterator:
     @highestNumber.setter
     def highestNumber(self, value):
         if self._numberingSystem is None:
-            raise Exception('Numbering System is not set.')
+            raise ValueError('Numbering System is not set.')
         if self.iterationType == 'number':
             if self._numberingSystem == 'title':
                 if not self._titleList:
diff --git a/music21/dynamics.py b/music21/dynamics.py
index 69aaa4af1..bb7ba8f9a 100644
--- a/music21/dynamics.py
+++ b/music21/dynamics.py
@@ -348,6 +348,9 @@ class DynamicWedge(spanner.Spanner):
     def __init__(self, *spannedElements, **keywords):
         super().__init__(*spannedElements, **keywords)
 
+        # from music21 import note
+        # self.fillElementTypes = [note.GeneralNote]
+
         self.type = None  # crescendo or diminuendo
         self.placement = 'below'  # can above or below, after musicxml
         self.spread = 15  # this unit is in tenths
diff --git a/music21/expressions.py b/music21/expressions.py
index 5f639108d..4d97d98eb 100644
--- a/music21/expressions.py
+++ b/music21/expressions.py
@@ -27,9 +27,12 @@ from __future__ import annotations
 import copy
 import string
 import typing as t
+from fractions import Fraction
 
 from music21 import base
 from music21 import common
+from music21.common.enums import OrnamentDelay
+from music21.common.numberTools import opFrac
 from music21.common.types import OffsetQL
 from music21 import exceptions21
 from music21 import interval
@@ -1027,13 +1030,66 @@ class Turn(Ornament):
     A turn or Gruppetto.
 
     * Changed in v7: size is a Generic second.  removed unused nachschlag component.
+    * Changed in v9: Added support for delayed vs non-delayed Turn.
     '''
-    def __init__(self, **keywords):
+    def __init__(self, *, delay: OrnamentDelay | OffsetQL = OrnamentDelay.NO_DELAY, **keywords):
         super().__init__(**keywords)
-        self.size = interval.GenericInterval(2)
-        self.placement = 'above'
-        self.tieAttach = 'all'
-        self.quarterLength = 0.25
+        self.size: interval.IntervalBase = interval.GenericInterval(2)
+        self.placement: str = 'above'
+        self.tieAttach: str = 'all'
+        self.quarterLength: OffsetQL = 0.25
+        self._delay: OrnamentDelay | OffsetQL = 0.0
+        self.delay = delay  # use property setter
+
+    @property
+    def delay(self) -> OrnamentDelay | OffsetQL:
+        return self._delay
+
+    @delay.setter
+    def delay(self, newDelay: OrnamentDelay | OffsetQL):
+        # we convert to OrnamentDelay if possible now, to simplify life later
+        if isinstance(newDelay, (float, Fraction)) and newDelay <= 0:
+            newDelay = OrnamentDelay.NO_DELAY
+        self._delay = newDelay
+
+    @property
+    def isDelayed(self) -> bool:
+        # if self.delay is NO_DELAY, the turn is not delayed
+        # if self.delay is anything else (an OffsetQL or DEFAULT_DELAY), the turn is delayed
+        # Note that the implementation of the delay property ensures that if self.delay
+        # is an OffsetQL, it will always be > 0.
+        return self.delay != OrnamentDelay.NO_DELAY
+
+    @property
+    def name(self) -> str:
+        '''
+        returns the name of the Turn/InvertedTurn, which is generally the class
+        name lowercased, with spaces where a new capital occurs, but also with
+        a 'delayed' prefix, if the Turn/InvertedTurn is delayed.  If the delay
+        is of a specific duration, the prefix will include that duration.
+
+        Subclasses can override this as necessary.
+
+        >>> nonDelayedTurn = expressions.Turn()
+        >>> nonDelayedTurn.name
+        'turn'
+
+        >>> from music21.common.enums import OrnamentDelay
+        >>> delayedInvertedTurn = expressions.InvertedTurn(delay=OrnamentDelay.DEFAULT_DELAY)
+        >>> delayedInvertedTurn.name
+        'delayed inverted turn'
+
+        >>> delayedBy1Turn = expressions.Turn(delay=1.0)
+        >>> delayedBy1Turn.name
+        'delayed(delayQL=1.0) turn'
+
+        '''
+        superName: str = super().name
+        if self.delay == OrnamentDelay.DEFAULT_DELAY:
+            return 'delayed ' + superName
+        elif isinstance(self.delay, (float, Fraction)):
+            return f'delayed(delayQL={self.delay}) ' + superName
+        return superName
 
     def realize(self, srcObj: 'music21.note.Note', *, inPlace=False):
         # noinspection PyShadowingNames
@@ -1041,28 +1097,31 @@ class Turn(Ornament):
         realize a turn.
 
         returns a three-element tuple.
-        The first is a list of the four notes that the beginning of the note was converted to.
-        The second is a note of duration 0 because the turn "eats up" the whole note.
-        The third is a list of the notes at the end if nachschlag is True,
-        and empty list if False.
+        The first element is an empty list because there are no notes at the start of a turn.
+        The second element is the original note with a duration equal to the delay (but if there
+        is no delay, the second element is None, because the turn "eats up" the entire note).
+        The third element is a list of the four turn notes, adding up to the duration of the
+        original note (less the delay, if there is one).  The four turn notes will either be
+        of equal duration, or the fourth note will be longer, to "eat up" the entire note.
 
         >>> from  music21 import *
+        >>> from music21.common.enums import OrnamentDelay
         >>> m1 = stream.Measure()
         >>> m1.append(key.Key('F', 'major'))
         >>> n1 = note.Note('C5')
         >>> m1.append(n1)
         >>> t1 = expressions.Turn()
         >>> t1.realize(n1)
-        ([], <music21.note.Note C>, [<music21.note.Note D>,
-                                     <music21.note.Note C>,
-                                     <music21.note.Note B->,
-                                     <music21.note.Note C>])
+        ([], None, [<music21.note.Note D>,
+                    <music21.note.Note C>,
+                    <music21.note.Note B->,
+                    <music21.note.Note C>])
 
         >>> m2 = stream.Measure()
         >>> m2.append(key.KeySignature(5))
         >>> n2 = note.Note('B4', type='quarter')
         >>> m2.append(n2)
-        >>> t2 = expressions.InvertedTurn()
+        >>> t2 = expressions.InvertedTurn(delay=OrnamentDelay.DEFAULT_DELAY)
         >>> n2.expressions.append(t2)
         >>> t2.realize(n2)
         ([], <music21.note.Note B>, [<music21.note.Note A#>,
@@ -1082,18 +1141,42 @@ class Turn(Ornament):
 
         >>> n2 = note.Note('C4')
         >>> n2.duration.type = '32nd'
-        >>> t2 = expressions.Turn()
-        >>> _empty, _, turnNotes = t2.realize(n2, inPlace=True)
+        >>> t2 = expressions.Turn(delay=OrnamentDelay.DEFAULT_DELAY)
+        >>> _empty, newOrigNote, turnNotes = t2.realize(n2, inPlace=True)
         >>> for turnNote in turnNotes:
         ...     print(turnNote, turnNote.duration.type)
-        <music21.note.Note D> 128th
-        <music21.note.Note C> 128th
-        <music21.note.Note B> 128th
-        <music21.note.Note C> 128th
+        <music21.note.Note D> 256th
+        <music21.note.Note C> 256th
+        <music21.note.Note B> 256th
+        <music21.note.Note C> 256th
         >>> n2.duration.type
-        'zero'
+        '64th'
         >>> n2.expressions
         []
+        >>> newOrigNote is n2
+        True
+
+        If the four turn notes (self.quarterLength each) don't add up to the original note
+        duration, the fourth turn note should be held to the length of any remaining unused
+        duration.  Here, for example, we have a dotted eighth note total duration, a delay
+        of a 16th note, and a turn note duration of a triplet 32nd note, leaving the fourth
+        turn note with a duration of a 16th note.  This sort of turn is seen all over the
+        music of Weber.
+
+        >>> from fractions import Fraction
+        >>> n3 = note.Note('C4')
+        >>> n3.quarterLength = 0.75
+        >>> t3 = expressions.Turn(delay=0.25)
+        >>> t3.quarterLength = 0.125 * Fraction(2, 3)
+        >>> _empty, newOrigNote, turnNotes = t3.realize(n3, inPlace=True)
+        >>> print(newOrigNote, newOrigNote.quarterLength)
+        <music21.note.Note C> 0.25
+        >>> for turnNote in turnNotes:
+        ...     print(turnNote, turnNote.quarterLength)
+        <music21.note.Note D> 1/12
+        <music21.note.Note C> 1/12
+        <music21.note.Note B> 1/12
+        <music21.note.Note C> 0.25
 
         If `.autoScale` is off and the note is not long enough to realize 4
         32nd notes, then an exception is raised.
@@ -1112,12 +1195,31 @@ class Turn(Ornament):
             raise ExpressionException('Cannot realize a turn if there is no size given')
         if srcObj.duration.quarterLength == 0:
             raise ExpressionException('Cannot steal time from an object with no duration')
-        if srcObj.duration.quarterLength < 4 * self.quarterLength:
+
+        remainderDuration: OffsetQL
+        if self.delay == OrnamentDelay.NO_DELAY:
+            remainderDuration = 0.0
+        elif self.delay == OrnamentDelay.DEFAULT_DELAY:
+            # half the duration of the srcObj note
+            remainderDuration = opFrac(srcObj.duration.quarterLength / 2)
+        else:
+            theDelay = self.delay
+            if t.TYPE_CHECKING:
+                assert isinstance(theDelay, (float, Fraction))
+            remainderDuration = theDelay
+
+        turnDuration = srcObj.duration.quarterLength - remainderDuration
+        fourthNoteQL: OffsetQL | None = None
+        if turnDuration < 4 * self.quarterLength:
             if not self.autoScale:
                 raise ExpressionException('The note is not long enough to realize a turn')
-            useQL = srcObj.duration.quarterLength / 4
+            useQL = opFrac(turnDuration / 4)
+        elif turnDuration > 4 * self.quarterLength:
+            # in this case, we keep the first 3 turn notes as self.quarterLength, and
+            # extend the 4th turn note to finish up the turnDuration
+            useQL = self.quarterLength
+            fourthNoteQL = opFrac(turnDuration - (3 * useQL))
 
-        remainderDuration = srcObj.duration.quarterLength - 4 * useQL
         transposeIntervalUp = self.size
         transposeIntervalDown = self.size.reverse()
 
@@ -1139,7 +1241,10 @@ class Turn(Ornament):
 
         fourthNote = copy.deepcopy(srcObj)
         fourthNote.expressions = []
-        fourthNote.duration.quarterLength = useQL
+        if fourthNoteQL is None:
+            fourthNote.duration.quarterLength = useQL
+        else:
+            fourthNote.duration.quarterLength = fourthNoteQL
 
         turnNotes.append(firstNote)
         turnNotes.append(secondNote)
@@ -1158,6 +1263,9 @@ class Turn(Ornament):
         if self in srcObj.expressions:
             inExpressions = srcObj.expressions.index(self)
 
+        if remainderDuration == 0:
+            return ([], None, turnNotes)
+
         if not inPlace:
             remainderNote = copy.deepcopy(srcObj)
         else:
@@ -1170,8 +1278,8 @@ class Turn(Ornament):
 
 
 class InvertedTurn(Turn):
-    def __init__(self, **keywords):
-        super().__init__(**keywords)
+    def __init__(self, *, delay: OrnamentDelay | OffsetQL = OrnamentDelay.NO_DELAY, **keywords):
+        super().__init__(delay=delay, **keywords)
         self.size = self.size.reverse()
 
 
@@ -1437,6 +1545,10 @@ class TrillExtension(spanner.Spanner):
     # N.B. this extension always includes a trill symbol
     def __init__(self, *spannedElements, **keywords):
         super().__init__(*spannedElements, **keywords)
+
+        # from music21 import note
+        # self.fillElementTypes = [note.NotRest]
+
         self._placement = None  # can above or below or None, after musicxml
 
     def _getPlacement(self):
@@ -1484,6 +1596,7 @@ class TremoloSpanner(spanner.Spanner):
 
     def __init__(self, *spannedElements, **keywords):
         super().__init__(*spannedElements, **keywords)
+
         self.placement = None
         self.measured = True
         self._numberOfMarks = 3
@@ -1565,6 +1678,7 @@ class ArpeggioMarkSpanner(spanner.Spanner):
                  arpeggioType: str = 'normal',
                  **keywords):
         super().__init__(*spannedElements, **keywords)
+
         if arpeggioType not in ('normal', 'up', 'down', 'non-arpeggio'):
             raise ValueError(
                 'Arpeggio type must be "normal", "up", "down", or "non-arpeggio", '
diff --git a/music21/features/base.py b/music21/features/base.py
index e1c35567d..06f61874c 100644
--- a/music21/features/base.py
+++ b/music21/features/base.py
@@ -981,7 +981,7 @@ class DataSet:
                 # in some cases there might be problem; to not fail
                 try:
                     fReturned = fe.extract()
-                except Exception as e:  # pylint: disable=broad-except
+                except Exception as e:  # pylint: disable=broad-exception-caught
                     # for now take any error
                     fList = ['failed feature extractor:', fe, str(e)]
                     if self.quiet is True:
@@ -1100,7 +1100,7 @@ def _dataSetParallelSubprocess(dataInstance, failFast):
         # in some cases there might be problem; to not fail
         try:
             fReturned = fe.extract()
-        except Exception as e:  # pylint: disable=broad-except
+        except Exception as e:  # pylint: disable=broad-exception-caught
             # for now take any error
             errors.append('failed feature extractor:' + str(fe) + ': ' + str(e))
             if failFast:
diff --git a/music21/freezeThaw.py b/music21/freezeThaw.py
index d2d03cef6..2b08ed999 100644
--- a/music21/freezeThaw.py
+++ b/music21/freezeThaw.py
@@ -513,10 +513,10 @@ class StreamFreezer(StreamFreezeThawBase):
         >>> foundIds = sf.findActiveStreamIdsInHierarchy()
         >>> for thisId in shouldFindIds:
         ...     if thisId not in foundIds:
-        ...         raise Exception('Missing Id')
+        ...         raise ValueError('Missing Id')
         >>> for thisId in foundIds:
         ...     if thisId not in shouldFindIds:
-        ...         raise Exception('Additional Id Found')
+        ...         raise ValueError('Additional Id Found')
 
         Spanners are included unless getSpanners is False
 
diff --git a/music21/graph/primitives.py b/music21/graph/primitives.py
index e9ab61d93..17856222c 100644
--- a/music21/graph/primitives.py
+++ b/music21/graph/primitives.py
@@ -851,7 +851,7 @@ class GraphColorGridLegend(Graph):
         >>> fig = matplotlib.pyplot.figure()
         >>> subplot = colorLegend.makeOneRowOfGraph(fig, 0, 'Scriabin Mapping', rowData)
         >>> subplot
-        <AxesSubplot: >
+        <Axes: >
         '''
         # environLocal.printDebug(['rowLabel', rowLabel, i])
 
diff --git a/music21/harmony.py b/music21/harmony.py
index 7f94035dd..bc26d7592 100644
--- a/music21/harmony.py
+++ b/music21/harmony.py
@@ -245,9 +245,10 @@ class Harmony(chord.Chord):
             self.bass(self._overrides['root'], allow_add=True)
 
         if (updatePitches
-                and self._figure  # == '' or is not None
-                or 'root' in self._overrides
-                or 'bass' in self._overrides):
+                and (
+                    self._figure  # == '' or is not None
+                    or 'root' in self._overrides
+                    or 'bass' in self._overrides)):
             self._updatePitches()
         self._updateFromParameters(root=root, bass=bass, inversion=inversion)
 
@@ -598,7 +599,7 @@ class ChordStepModification(prebase.ProtoM21Object):
 
     # INITIALIZER #
 
-    def __init__(self, modType=None, degree=None, intervalObj=None):
+    def __init__(self, modType=None, degree=None, intervalObj=None) -> None:
         self._modType: str | None = None  # add, alter, subtract
         self._interval: interval.Interval  # alteration of degree, alter ints in mxl
         self._degree: int | None = None  # the degree number, where 3 is the third
@@ -3185,6 +3186,12 @@ class Test(unittest.TestCase):
         realizeChordSymbolDurations(m)
         self.assertEqual(cs.quarterLength, 3.0)
 
+    def testUpdatePitchesFalse(self):
+        bass_note = pitch.Pitch('C3')
+        h = Harmony(bass=bass_note, updatePitches=False)
+        # No other pitches are created
+        self.assertEqual(h.pitches, (bass_note,))
+
 
 class TestExternal(unittest.TestCase):
 
diff --git a/music21/humdrum/spineParser.py b/music21/humdrum/spineParser.py
index dde3f5fa0..c1f8cd295 100644
--- a/music21/humdrum/spineParser.py
+++ b/music21/humdrum/spineParser.py
@@ -1339,7 +1339,7 @@ class KernSpine(HumdrumSpine):
                     thisObject.humdrumPosition = event.position
                     thisObject.priority = event.position
                     self.stream.coreAppend(thisObject)
-            except Exception as e:  # pylint: disable=broad-except  # pragma: no cover
+            except Exception as e:  # pylint: disable=broad-exception-caught  # pragma: no cover
                 import traceback
                 environLocal.warn(
                     f'Error in parsing event ({event.contents!r}) '
diff --git a/music21/interval.py b/music21/interval.py
index 234f2a2b7..28abe033f 100644
--- a/music21/interval.py
+++ b/music21/interval.py
@@ -3443,6 +3443,12 @@ class Interval(IntervalBase):
         PRIVATE METHOD: Return p even if inPlace is True
         '''
         # NOTE: this is a performance critical method
+
+        inheritAccidentalDisplayStatus: bool = False
+        if self.simpleName == 'P1' and float(self.semitones) == float(int(self.semitones)):
+            # true unison and any multiple of true octave
+            inheritAccidentalDisplayStatus = True
+
         if p.octave is None:
             useImplicitOctave = True
         else:
@@ -3459,6 +3465,7 @@ class Interval(IntervalBase):
         newStep, newOctave = convertDiatonicNumberToStep(newDiatonicNumber)
         pitch2.step = newStep
         pitch2.octave = newOctave
+        oldPitch2Accidental = pitch2.accidental
         pitch2.accidental = None
         # if this is not set to None then terrible things happen
         pitch2.microtone = None  # type: ignore
@@ -3487,13 +3494,35 @@ class Interval(IntervalBase):
                 # pitch2 = pitchAlt
                 pitch2.ps = pitch2.ps + halfStepsToFix
             else:
-                # cannot import Accidental here.
                 pitch2.accidental = halfStepsToFix  # type:ignore
 
-            # inherit accidental display type etc. but not current status
-            if pitch2.accidental is not None and pitch1.accidental is not None:
-                pitch2.accidental.inheritDisplay(pitch1.accidental)
-                pitch2.accidental.displayStatus = None  # set accidental display to None
+            if not inheritAccidentalDisplayStatus:
+                # inherit accidental display type etc. but not current status
+                if pitch2.accidental is not None and pitch1.accidental is not None:
+                    pitch2.accidental.inheritDisplay(pitch1.accidental)
+                    pitch2.accidental.displayStatus = None  # set accidental display to None
+            else:
+                # inherit all accidental display options (including status)
+                if pitch2.accidental is None:
+                    if pitch1.accidental is not None:
+                        pitch2.accidental = 0  # type:ignore
+                        if t.TYPE_CHECKING:
+                            assert pitch2.accidental is not None
+                        pitch2.accidental.inheritDisplay(pitch1.accidental)
+                else:
+                    if pitch1.accidental is not None:
+                        pitch2.accidental.inheritDisplay(pitch1.accidental)
+                    else:
+                        pitch2.accidental.displayStatus = False
+
+        else:
+            # no halfStepsToFix, so pitch2 is fine as is, but...
+            if inheritAccidentalDisplayStatus:
+                # We have set pitch2.accidental to None, so we might have lost some
+                # display options. So we restore oldPitch2Accidental if that makes sense.
+                if (oldPitch2Accidental is not None
+                        and oldPitch2Accidental.name == 'natural'):
+                    pitch2.accidental = oldPitch2Accidental
 
         if useImplicitOctave is True:
             pitch2.octave = None
diff --git a/music21/lily/translate.py b/music21/lily/translate.py
index f93fb4be3..1b2b7d508 100644
--- a/music21/lily/translate.py
+++ b/music21/lily/translate.py
@@ -1087,7 +1087,7 @@ class LilypondConverter:
             currentMusicList.append(lyObject)
             lyObject.setParent(contextObject)
             # except AttributeError as ae:
-            #    raise Exception('Cannot parse %s: %s' % (thisObject, str(ae)))
+            #    raise ValueError('Cannot parse %s: %s' % (thisObject, str(ae)))
         elif 'Note' in c or 'Rest' in c:
             self.appendContextFromNoteOrRest(thisObject)
         elif 'Chord' in c:
@@ -2490,7 +2490,7 @@ class LilypondConverter:
         '''
         lF = self.createPDF()
         if not lF.exists():  # pragma: no cover
-            raise Exception('Something went wrong with PDF Creation')
+            raise RuntimeError('Something went wrong with PDF Creation')
 
         if os.name == 'nt':
             command = f'start /wait {str(lF)} && del /f {str(lF)}'
@@ -2517,7 +2517,7 @@ class LilypondConverter:
                 lilyImage = Image.open(str(lilyFile))
                 lilyImage2 = ImageOps.expand(lilyImage, 10, 'white')  # type: ignore
                 lilyImage2.save(str(lilyFile))
-            except Exception:  # pylint: disable=broad-except
+            except Exception:  # pylint: disable=broad-exception-caught
                 pass  # no big deal probably...
         return lilyFile
 
diff --git a/music21/metadata/caching.py b/music21/metadata/caching.py
index 6dff49531..63f0882d8 100644
--- a/music21/metadata/caching.py
+++ b/music21/metadata/caching.py
@@ -132,7 +132,7 @@ class MetadataCachingJob:
                 parsedObject = converter.parse(self.filePath, forceSource=True)
             else:
                 parsedObject = corpus.parse(str(self.filePath), forceSource=True)
-        except Exception as e:  # wide catch is fine. pylint: disable=broad-except
+        except Exception as e:  # wide catch is fine. pylint: disable=broad-exception-caught
             environLocal.printDebug(f'parse failed: {self.filePath}, {e}')
             environLocal.printDebug(traceback.format_exc())
             self.filePathErrors.append(self.filePath)
@@ -167,7 +167,7 @@ class MetadataCachingJob:
                     corpusName=self.corpusName,
                 )
                 self.results.append(metadataEntry)
-        except Exception:  # wide catch is fine. pylint: disable=broad-except
+        except Exception:  # wide catch is fine. pylint: disable=broad-exception-caught
             environLocal.warn('Had a problem with extracting metadata '
                               'for {0}, piece ignored'.format(self.filePath))
             environLocal.warn(traceback.format_exc())
@@ -182,7 +182,7 @@ class MetadataCachingJob:
             for scoreNumber, score in enumerate(parsedObject.scores):
                 self.parseScoreInsideOpus(score, scoreNumber)
                 del score  # for memory conservation
-        except Exception as exception:  # wide catch is fine. pylint: disable=broad-except
+        except Exception as exception:  # wide catch is fine. pylint: disable=broad-exception-caught
             environLocal.warn(
                 'Had a problem with extracting metadata for score {0} '
                 'in {1}, whole opus ignored: {2}'.format(
@@ -226,7 +226,7 @@ class MetadataCachingJob:
                     metadataPayload=richMetadata,
                 )
                 self.results.append(metadataEntry)
-        except Exception as exception:  # pylint: disable=broad-except
+        except Exception as exception:  # pylint: disable=broad-exception-caught
             environLocal.warn(
                 'Had a problem with extracting metadata '
                 'for score {0} in {1}, whole opus ignored: '
diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py
index fcb95da4f..c71de9596 100644
--- a/music21/musicxml/m21ToXml.py
+++ b/music21/musicxml/m21ToXml.py
@@ -1517,9 +1517,8 @@ class ScoreExporter(XMLExporterBase, PartStaffExporterMixin):
         if not s:
             return self.emptyObject()
 
-        if s.atSoundingPitch is True:
-            # A copy was already made or elected NOT to be made.
-            s.toWrittenPitch(inPlace=True)
+        # A copy was already made or elected NOT to be made.
+        s.toWrittenPitch(inPlace=True, ottavasToSounding=True)
 
         self.scorePreliminaries()
 
@@ -2730,8 +2729,7 @@ class PartExporter(XMLExporterBase):
         # unless makeNotation=False, but the user
         # should have called toWrittenPitch() first
         # and is explicitly asking for no further copies to be made
-        if self.stream.atSoundingPitch is True:
-            self.stream.toWrittenPitch(inPlace=True)
+        self.stream.toWrittenPitch(inPlace=True, ottavasToSounding=True)
 
         # Suppose that everything below this is a measure
         if self.makeNotation:
@@ -3143,7 +3141,6 @@ class MeasureExporter(XMLExporterBase):
             ('RehearsalMark', 'rehearsalMarkToXml'),
         ]
     )
-
     # these need to be wrapped in an attributes tag if not at the beginning of the measure.
     wrapAttributeMethodClasses = OrderedDict(
         [('Clef', 'clefToXml'),
@@ -3834,6 +3831,80 @@ class MeasureExporter(XMLExporterBase):
 
         return notations
 
+    def objectAttachedSpannersToTechnicals(self, obj, objectSpannerBundle=None):
+        '''
+        return a list of <technical> from spanners related to the object that should appear
+        in the technical tag of the notations tag (hammer-on, pull-off etc.)
+
+        >>> n0 = note.Note('C')
+        >>> n1 = note.Note('D')
+        >>> n2 = note.Note('F')
+        >>> n3 = note.Note('E')
+        >>> hammerOn01 = articulations.HammerOn([n0, n1])
+        >>> pullOff23 = articulations.PullOff([n2, n3])
+        >>> m = stream.Measure()
+        >>> m.insert(0, hammerOn01)
+        >>> m.insert(0, pullOff23)
+        >>> m.append(n0)
+        >>> m.append(n1)
+        >>> m.append(n2)
+        >>> m.append(n3)
+        >>> mex = musicxml.m21ToXml.MeasureExporter(m)
+        >>> out = mex.objectAttachedSpannersToTechnicals(n0, m.spannerBundle)
+        >>> out
+        [<Element 'hammer-on' at 0x102857f40>]
+        >>> mex.dump(out[0])
+        <hammer-on number="1" type="start">H</hammer-on>
+        >>> out = mex.objectAttachedSpannersToTechnicals(n3, m.spannerBundle)
+        >>> mex.dump(out[0])
+        <pull-off number="1" type="stop" />
+
+        OMIT_FROM_DOCS
+
+        The other permutations of class and first/last:
+
+        >>> out = mex.objectAttachedSpannersToTechnicals(n1, m.spannerBundle)
+        >>> mex.dump(out[0])
+        <hammer-on number="1" type="stop" />
+        >>> out = mex.objectAttachedSpannersToTechnicals(n2, m.spannerBundle)
+        >>> mex.dump(out[0])
+        <pull-off number="1" type="start">P</pull-off>
+        '''
+        technicals = []
+        if objectSpannerBundle is not None:
+            sb = objectSpannerBundle
+        else:
+            sb = self.objectSpannerBundle
+
+        if not sb:
+            return technicals
+
+        for su in sb.getByClass(articulations.HammerOn):
+            mxHammerOn = Element('hammer-on')
+            if su.isFirst(obj):
+                mxHammerOn.set('type', 'start')
+                mxHammerOn.text = 'H'
+            elif su.isLast(obj):
+                mxHammerOn.set('type', 'stop')
+            else:
+                continue
+            mxHammerOn.set('number', '1')
+            technicals.append(mxHammerOn)
+
+        for su in sb.getByClass(articulations.PullOff):
+            mxPullOff = Element('pull-off')
+            if su.isFirst(obj):
+                mxPullOff.set('type', 'start')
+                mxPullOff.text = 'P'
+            elif su.isLast(obj):
+                mxPullOff.set('type', 'stop')
+            else:
+                continue
+            mxPullOff.set('number', '1')
+            technicals.append(mxPullOff)
+
+        return technicals
+
     def appendArpeggioMarkSpannersToNotations(
         self,
         obj: base.Music21Object,
@@ -4836,7 +4907,8 @@ class MeasureExporter(XMLExporterBase):
                 else:
                     notations.append(mxExpression)
 
-        # apply all articulations apart from fingerings only to first note of chord
+        # apply all articulations apart from fingerings and hammer-on/pull-off
+        # only to first note of chord
         applicableArticulations = []
         fingeringNumber = 0
         for a in chordOrNote.articulations:
@@ -4845,7 +4917,14 @@ class MeasureExporter(XMLExporterBase):
                     applicableArticulations.append(a)
                 fingeringNumber += 1
             elif isSingleNoteOrFirstInChord:
-                applicableArticulations.append(a)
+                # Ignore hammer-on/pull-off:
+                # are written from their Spanner representation instead.
+                # It's an anti-pattern to find spanners in the articulations
+                # array, and the musicxml importer doesn't put them here,
+                # but it's a potential point of user confusion, so we guard
+                # against it here to avoid writing out superfluous <other-technical>
+                if not isinstance(a, (articulations.HammerOn, articulations.PullOff)):
+                    applicableArticulations.append(a)
 
         for artObj in applicableArticulations:
             if isinstance(artObj, articulations.Pizzicato):
@@ -4855,6 +4934,7 @@ class MeasureExporter(XMLExporterBase):
             if isinstance(artObj, articulations.TechnicalIndication):
                 if mxTechnicalMark is None:
                     mxTechnicalMark = Element('technical')
+
                 mxTechnicalMark.append(self.articulationToXmlTechnical(artObj))
             else:
                 if mxArticulations is None:
@@ -4880,6 +4960,17 @@ class MeasureExporter(XMLExporterBase):
         # TODO: glissando
         # TODO: slide
 
+        if isSingleNoteOrFirstInChord and chordParent is not None:
+            spannerTechnicals = self.objectAttachedSpannersToTechnicals(chordParent)
+        elif chordParent is not None:
+            spannerTechnicals = None
+        else:
+            spannerTechnicals = self.objectAttachedSpannersToTechnicals(n)
+        if spannerTechnicals:
+            if mxTechnicalMark is None:
+                mxTechnicalMark = Element('technical')
+            mxTechnicalMark.extend(spannerTechnicals)
+
         for x in (mxArticulations,
                   mxTechnicalMark,
                   mxOrnaments):
@@ -5123,6 +5214,13 @@ class MeasureExporter(XMLExporterBase):
         >>> MEX.dump(mxExpression)
         <inverted-turn placement="above" />
 
+        >>> invDelayedTurn = expressions.InvertedTurn(delay=1.)
+        >>> invDelayedTurn.placement = 'below'
+        >>> MEX = musicxml.m21ToXml.MeasureExporter()
+        >>> mxExpression = MEX.expressionToXml(invDelayedTurn)
+        >>> MEX.dump(mxExpression)
+        <delayed-inverted-turn placement="below" />
+
         Some special types...
 
         >>> f = expressions.Fermata()
@@ -5158,40 +5256,52 @@ class MeasureExporter(XMLExporterBase):
         >>> MEX.dump(mxExpression)
         <non-arpeggiate />
         '''
-        mapping = OrderedDict([
-            ('Trill', 'trill-mark'),
-            # TODO: delayed-inverted-turn
-            # TODO: vertical-turn
-            # TODO: 'delayed-turn'
-            ('InvertedTurn', 'inverted-turn'),
-            # last as others are subclasses
-            ('Turn', 'turn'),
-            ('InvertedMordent', 'inverted-mordent'),
-            ('Mordent', 'mordent'),
-            ('Shake', 'shake'),
-            ('Schleifer', 'schleifer'),
-            # TODO: 'accidental-mark'
-            ('Tremolo', 'tremolo'),  # non-spanner
-            # non-ornaments...
-            ('Fermata', 'fermata'),
-            # keep last...
-            ('Ornament', 'other-ornament'),
-        ])
         mx = None
         classes = expression.classes
-        for k, v in mapping.items():
-            if k in classes:
-                mx = Element(v)
-                break
-        if mx is None:
-            # ArpeggioMark maps to two different elements
-            if isinstance(expression, expressions.ArpeggioMark):
-                if expression.type == 'non-arpeggio':
-                    mx = Element('non-arpeggiate')
+
+        # ArpeggioMark maps to two different elements
+        if isinstance(expression, expressions.ArpeggioMark):
+            if expression.type == 'non-arpeggio':
+                mx = Element('non-arpeggiate')
+            else:
+                mx = Element('arpeggiate')
+                if expression.type != 'normal':
+                    mx.set('direction', expression.type)
+
+        # InvertedTurn/Turn map to two different elements each
+        if isinstance(expression, expressions.Turn):
+            if isinstance(expression, expressions.InvertedTurn):
+                if expression.isDelayed:
+                    mx = Element('delayed-inverted-turn')
+                else:
+                    mx = Element('inverted-turn')
+            else:
+                if expression.isDelayed:
+                    mx = Element('delayed-turn')
                 else:
-                    mx = Element('arpeggiate')
-                    if expression.type != 'normal':
-                        mx.set('direction', expression.type)
+                    mx = Element('turn')
+
+        if mx is None:
+            mapping = OrderedDict([
+                ('Trill', 'trill-mark'),
+                # TODO: vertical-turn
+                ('InvertedMordent', 'inverted-mordent'),
+                ('Mordent', 'mordent'),
+                ('Shake', 'shake'),
+                ('Schleifer', 'schleifer'),
+                # TODO: 'accidental-mark'
+                ('Tremolo', 'tremolo'),  # non-spanner
+                # non-ornaments...
+                ('Fermata', 'fermata'),
+                # keep last...
+                ('Ornament', 'other-ornament'),
+            ])
+
+            for k, v in mapping.items():
+                if k in classes:
+                    mx = Element(v)
+                    break
+
         if mx is None:
             environLocal.printDebug(['no musicxml conversion for:', expression])
             return
@@ -5326,14 +5436,6 @@ class MeasureExporter(XMLExporterBase):
         >>> mxOther = MEX.articulationToXmlTechnical(g)
         >>> MEX.dump(mxOther)
         <other-technical>unda maris</other-technical>
-
-        Same with technical marks not yet supported.
-        TODO: support HammerOn, PullOff, Bend, Hole, Arrow.
-
-        >>> h = articulations.HammerOn()
-        >>> mxOther = MEX.articulationToXmlTechnical(h)
-        >>> MEX.dump(mxOther)
-        <other-technical />
         '''
         # these technical have extra information
         # TODO: hammer-on
@@ -5350,7 +5452,7 @@ class MeasureExporter(XMLExporterBase):
             musicXMLTechnicalName = 'other-technical'
 
         # TODO: support additional technical marks listed above
-        if musicXMLTechnicalName in ('hammer-on', 'pull-off', 'bend', 'hole', 'arrow'):
+        if musicXMLTechnicalName in ('bend', 'hole', 'arrow'):
             musicXMLTechnicalName = 'other-technical'
 
         mxTechnicalMark = Element(musicXMLTechnicalName)
diff --git a/music21/musicxml/xmlObjects.py b/music21/musicxml/xmlObjects.py
index 5813209f5..fb241ea97 100644
--- a/music21/musicxml/xmlObjects.py
+++ b/music21/musicxml/xmlObjects.py
@@ -67,8 +67,10 @@ TECHNICAL_MARKS = OrderedDict([('up-bow', articulations.UpBow),
                                ('stopped', articulations.Stopped),
                                ('snap-pizzicato', articulations.SnapPizzicato),
                                ('string', articulations.StringIndication),
-                               ('hammer-on', articulations.HammerOn),
-                               ('pull-off', articulations.PullOff),
+                               # hammer-on and pull-off not implemented because handled
+                               # in method objectAttachedSpannersToTechnicals of m21ToXml.py
+                               # ('hammer-on', articulations.HammerOn),
+                               # ('pull-off', articulations.PullOff),
                                # bend not implemented because it needs many subcomponents
                                # ('bend', articulations.FretBend),
                                ('tap', articulations.FretTap),
@@ -90,9 +92,9 @@ TECHNICAL_MARKS_REV[articulations.Harmonic] = 'harmonic'
 # NON-spanner ornaments that go into Expressions
 ORNAMENT_MARKS = {'trill-mark': expressions.Trill,
                   'turn': expressions.Turn,
-                  # TODO: 'delayed-turn'
+                  'delayed-turn': expressions.Turn,
                   'inverted-turn': expressions.InvertedTurn,
-                  # TODO: 'delayed-inverted-turn'
+                  'delayed-inverted-turn': expressions.InvertedTurn,
                   # TODO: 'vertical-turn'
                   'shake': expressions.Shake,
                   'mordent': expressions.Mordent,
diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py
index f5648f495..21bf43531 100644
--- a/music21/musicxml/xmlToM21.py
+++ b/music21/musicxml/xmlToM21.py
@@ -30,6 +30,7 @@ from music21 import common
 from music21 import defaults
 from music21 import duration
 from music21 import dynamics
+from music21.common.enums import OrnamentDelay
 from music21 import editorial
 from music21 import environment
 from music21 import exceptions21
@@ -1571,23 +1572,67 @@ class PartParser(XMLParserBase):
         # will be copied into the Score
 
         # copy spanners that are complete into the part, as this is the
-        # highest level container that needs them
-        rm = []
+        # highest level container that needs them. Ottavas are the exception,
+        # they should be put in the PartStaff that contains the first note
+        # in the Ottava.
+        completedSpanners: list[spanner.Spanner] = []
         for sp in self.spannerBundle.getByCompleteStatus(True):
-            self.stream.coreInsert(0, sp)
-            rm.append(sp)
+            if not isinstance(sp, spanner.Ottava):
+                # don't insert Ottavas, we'll do that after separateOutPartStaves().
+                self.stream.coreInsert(0, sp)
+            completedSpanners.append(sp)
         # remove from original spanner bundle
-        for sp in rm:
+        for sp in completedSpanners:
             self.spannerBundle.remove(sp)
         # s is the score; adding the part to the score
         self.stream.coreElementsChanged()
 
+        partStaves: list[stream.PartStaff] = []
         if self.maxStaves > 1:
-            self.separateOutPartStaves()
+            partStaves = self.separateOutPartStaves()
         else:
             self.stream.addGroupForElements(self.partId)  # set group for components (recurse?)
             self.stream.groups.append(self.partId)  # set group for stream itself
 
+        self._fillAndInsertOttavasInPartStaff(completedSpanners, partStaves)
+
+    def _fillAndInsertOttavasInPartStaff(
+        self,
+        spanners: list[spanner.Spanner],
+        partStaves: list[stream.PartStaff]
+    ):
+        # Ottavas should be filled, so that later transpositions can find all the notes that
+        # should be octave-shifted.  Ottavas should also be inserted into the partStaff that
+        # contains the Ottava's first note.
+        for sp in spanners:
+            if not isinstance(sp, spanner.Ottava):
+                continue
+            spannerPart: stream.Part | None = None
+            if partStaves:
+                spannerPart = self._findFirstPartStaffContaining(sp.getFirst(), partStaves)
+            else:
+                spannerPart = self.stream
+
+            if spannerPart is not None:
+                spannerPart.coreInsert(0, sp)
+                spannerPart.coreElementsChanged()
+                sp.fill(spannerPart)
+
+    def _findFirstPartStaffContaining(
+        self,
+        obj: base.Music21Object | None,
+        partStaves: list[stream.PartStaff]
+    ) -> stream.PartStaff | None:
+        if obj is None:
+            return None
+
+        for partStaff in partStaves:
+            if partStaff.containerInHierarchy(obj, setActiveSite=False) is not None:
+                # obj is somewhere in the hierarchy of this partStaff
+                return partStaff
+
+        return None
+
     def parseXmlScorePart(self):
         '''
         The <score-part> tag contains a lot of information about the
@@ -1812,7 +1857,7 @@ class PartParser(XMLParserBase):
         if lmp.stream.recurse().notesAndRests.last() is endedForwardRest:
             lmp.stream.remove(endedForwardRest, recurse=True)
 
-    def separateOutPartStaves(self) -> None:
+    def separateOutPartStaves(self) -> list[stream.PartStaff]:
         '''
         Take a `Part` with multiple staves and make them a set of `PartStaff` objects.
 
@@ -1832,7 +1877,7 @@ class PartParser(XMLParserBase):
         ]
 
         uniqueStaffKeys: list[int] = self._getUniqueStaffKeys()
-        partStaffs: list[stream.PartStaff] = []
+        partStaves: list[stream.PartStaff] = []
         appendedElementIds: set[int] = set()  # id is id(el) not el.id
 
         def copy_into_partStaff(source: stream.Stream,
@@ -1868,7 +1913,7 @@ class PartParser(XMLParserBase):
             # set group for components (recurse?)
             newPartStaff.addGroupForElements(partStaffId, setActiveSite=False)
             newPartStaff.groups.append(partStaffId)
-            partStaffs.append(newPartStaff)
+            partStaves.append(newPartStaff)
             self.parent.m21PartObjectsById[partStaffId] = newPartStaff
             elementsIdsNotToGoInThisStaff: set[int] = set()
             for staffReference in self.staffReferenceList:
@@ -1889,11 +1934,11 @@ class PartParser(XMLParserBase):
                 copyMeasure.flattenUnnecessaryVoices(force=False, inPlace=True)
 
         score = self.parent.stream
-        staffGroup = layout.StaffGroup(partStaffs, name=self.stream.partName, symbol='brace')
+        staffGroup = layout.StaffGroup(partStaves, name=self.stream.partName, symbol='brace')
         staffGroup.style.hideObjectOnPrint = True  # in truth, hide the name, not the brace
         score.coreInsert(0, staffGroup)
 
-        for partStaff in partStaffs:
+        for partStaff in partStaves:
             score.coreInsert(0, partStaff)
         score.coreElementsChanged()
 
@@ -1901,6 +1946,7 @@ class PartParser(XMLParserBase):
         # and thus that these next two lines are not needed:
         # score.remove(originalPartStaff)
         # del self.parent.m21PartObjectsById[originalPartStaff.id]
+        return partStaves
 
     def _getStaffExclude(
         self,
@@ -1980,7 +2026,7 @@ class PartParser(XMLParserBase):
             e.measureNumber = str(measureParser.measureNumber)
             e.partName = self.stream.partName
             raise e
-        except Exception as e:  # pylint: disable=broad-except
+        except Exception as e:  # pylint: disable=broad-exception-caught
             warnings.warn(
                 f'The following exception took place in m. {measureParser.measureNumber} in '
                 + f'part {self.stream.partName}.',
@@ -3970,12 +4016,16 @@ class MeasureParser(XMLParserBase):
         >>> a is None
         True
 
-        Not supported currently: 'accidental-mark', 'vertical-turn',
-        'delayed-turn', 'delayed-inverted-turn'
+        Not supported currently: 'accidental-mark', 'vertical-turn'
         '''
         tag = mxObj.tag
         try:
-            orn = xmlObjects.ORNAMENT_MARKS[tag]()
+            if tag in ('delayed-turn', 'delayed-inverted-turn'):
+                orn = xmlObjects.ORNAMENT_MARKS[tag](delay=OrnamentDelay.DEFAULT_DELAY)
+            elif tag in ('turn', 'inverted-turn'):
+                orn = xmlObjects.ORNAMENT_MARKS[tag](delay=OrnamentDelay.NO_DELAY)
+            else:
+                orn = xmlObjects.ORNAMENT_MARKS[tag]()
         except KeyError:  # should already be checked...
             return None
         self.setPrintStyle(mxObj, orn)
@@ -4159,6 +4209,12 @@ class MeasureParser(XMLParserBase):
                                     )
             self.setColor(mxObj, slur)
 
+        for mxObj in mxNotations.findall('technical/hammer-on'):
+            self.xmlOneSpanner(mxObj, n, articulations.HammerOn)
+
+        for mxObj in mxNotations.findall('technical/pull-off'):
+            self.xmlOneSpanner(mxObj, n, articulations.PullOff)
+
         for tagSearch in ('glissando', 'slide'):
             for mxObj in mxNotations.findall(tagSearch):
                 gliss = self.xmlOneSpanner(mxObj, n, spanner.Glissando)
diff --git a/music21/note.py b/music21/note.py
index 5fd4ad7ff..2178e0b6c 100644
--- a/music21/note.py
+++ b/music21/note.py
@@ -1252,7 +1252,7 @@ class NotRest(GeneralNote):
                 vol.velocity = value
 
         else:
-            raise Exception(f'this must be a Volume object, not {value}')
+            raise TypeError(f'this must be a Volume object, not {value}')
 
     @property
     def volume(self) -> volume.Volume:
diff --git a/music21/omr/correctors.py b/music21/omr/correctors.py
index a679c9b0b..773f71ddd 100644
--- a/music21/omr/correctors.py
+++ b/music21/omr/correctors.py
@@ -799,7 +799,7 @@ class MeasureHash:
         elif duration1to127 < 0:
             byteEncoding = chr(1)
         else:
-            raise Exception('Invalid Byte Encoding')
+            raise ValueError('Invalid Byte Encoding')
 
         return byteEncoding
 
@@ -1016,7 +1016,7 @@ class MeasureHash:
         if destination is None:
             destination = self.hashString
             if destination is None:
-                raise Exception('HashString has not yet been set!')
+                raise ValueError('HashString has not yet been set!')
 
         opCodeType = opCodeTuple[0]
         if opCodeType == 'equal':
@@ -1033,7 +1033,7 @@ class MeasureHash:
             numberOfAdditions = opCodeTuple[2] - opCodeTuple[1]
             return self.getProbabilityOnAddition() ** numberOfAdditions
         else:
-            raise Exception('Incorrect opCode type!')
+            raise ValueError('Incorrect opCodeType value.')
 
     def getProbabilityOnEquality(self):
         '''
diff --git a/music21/pitch.py b/music21/pitch.py
index b707c86ac..cb6a07ce2 100644
--- a/music21/pitch.py
+++ b/music21/pitch.py
@@ -407,7 +407,7 @@ def _convertCentsToAlterAndCents(shift) -> tuple[float, float]:
         alterShift = 1.0
         cents = value - 100
     else:  # pragma: no cover
-        raise Exception(f'value exceeded range: {value}')
+        raise ValueError(f'value exceeded range: {value}')
     return alterShift + alterAdd, float(cents)
 
 
diff --git a/music21/romanText/translate.py b/music21/romanText/translate.py
index 3e9784f26..be9970a55 100644
--- a/music21/romanText/translate.py
+++ b/music21/romanText/translate.py
@@ -381,7 +381,7 @@ class PartTranslator:
         for token in tokens:
             try:
                 self.translateOneLineToken(token)
-            except Exception:  # pylint: disable=broad-except
+            except Exception:  # pylint: disable=broad-exception-caught
                 tracebackMessage = traceback.format_exc()
                 raise RomanTextTranslateException(
                     f'At line {token.lineNumber} for token {token}, '
@@ -1157,11 +1157,16 @@ def fixPickupMeasure(partObject):
         curLastLength = mLast.duration.quarterLength
         lastRN.duration.quarterLength -= curLastLength - leftPadding
         mLast.paddingRight = curLastLength - leftPadding
-        i = -1
-        while partObject[i] is not mLast:
-            # unprocessed metadata after last object
-            partObject[i].setOffsetBySite(partObject, partObject[i].offset - mLast.paddingRight)
-            i -= 1
+
+        with common.classTools.tempAttribute(partObject, 'autoSort', False):
+            # cast as list for speed and also temporarily disable sorting until all done.
+            partElements = list(partObject)
+            i = -1
+            while partElements[i] is not mLast:
+                # unprocessed metadata after last object
+                partElements[i].setOffsetBySite(partObject,
+                                                partElements[i].offset - mLast.paddingRight)
+                i -= 1
 
 
 def romanTextToStreamOpus(rtHandler, inputM21=None):
@@ -1523,7 +1528,7 @@ m3 NC b3 G: V
         self.assertIn(roman.RomanNumeral, rn1.classSet)
         # s.show()
 
-    def testUnProcessed(self):
+    def testUnprocessed(self):
         from music21 import converter
         from music21.romanText import translate
         src = '''Note: Hello
@@ -1543,6 +1548,29 @@ Note: Hi
         self.assertFalse(var1.tag)
         self.assertIn(' I', var1.data)
 
+    def testUnprocessedWithAnacrusis(self):
+        from music21.romanText import translate
+        src = '''
+        Time Signature: 4/4
+        m0 b4 f: i
+        Note: Internal Note field after anacrusis.
+        m1 V
+        '''
+        s = translate.romanTextToStreamScore(src)
+        p = s.parts[0]
+        self.assertEqual(len(p), 3)
+        self.assertIsInstance(p[0], stream.Measure)
+        self.assertEqual(p[0].paddingLeft, 3.0)
+        self.assertIsInstance(p[1], translate.RomanTextUnprocessedMetadata)
+        self.assertEqual(p[1].offset, 0.0)
+        self.assertEqual(p[1].data, 'Internal Note field after anacrusis.')
+        self.assertIsInstance(p[2], stream.Measure)
+        self.assertEqual(p[2].offset, 1.0)
+        self.assertIsInstance(p[2][0], roman.RomanNumeral)
+        self.assertEqual(p[2].paddingRight, 1.0)
+        self.assertEqual(p[2].duration.quarterLength, 3.0)
+
+
     def testSixthMinorParse(self):
         from music21 import converter
 
diff --git a/music21/scale/intervalNetwork.py b/music21/scale/intervalNetwork.py
index 36a8f5bff..001da02d0 100644
--- a/music21/scale/intervalNetwork.py
+++ b/music21/scale/intervalNetwork.py
@@ -1262,7 +1262,7 @@ class IntervalNetwork:
         <music21.pitch.Pitch B2>
         '''
         if pitchOrigin is None:
-            raise Exception('No pitch origin for calling next on this pitch!')
+            raise TypeError('No pitch origin for calling next on this pitch!')
 
         if isinstance(pitchOrigin, str):
             pitchOriginObj = pitch.Pitch(pitchOrigin)
diff --git a/music21/search/segment.py b/music21/search/segment.py
index 59c5596fa..6987f2989 100644
--- a/music21/search/segment.py
+++ b/music21/search/segment.py
@@ -148,7 +148,7 @@ def _indexSingleMulticore(filePath, failFast=False, **keywords):
 
     try:
         indexOutput = indexOnePath(filePath, **keywords)
-    except Exception as e:  # pylint: disable=broad-except
+    except Exception as e:  # pylint: disable=broad-exception-caught
         if not failFast:
             print(f'Failed on parse/index for, {filePath}: {e}')
             indexOutput = ''
diff --git a/music21/sites.py b/music21/sites.py
index 40360673c..b156ef0d3 100644
--- a/music21/sites.py
+++ b/music21/sites.py
@@ -955,7 +955,7 @@ class Sites(common.SlottedObjectMixin):
             del self.siteDict[siteId]
             # environLocal.printDebug(['removed site w/o exception:', siteId,
             #    'self.siteDict.keys()', self.siteDict.keys()])
-        except Exception as e:  # pylint: disable=broad-except
+        except Exception as e:  # pylint: disable=broad-exception-caught
             raise SitesException(
                 'an entry for this object '
                 + f'({site}) is not stored in this Sites object'
diff --git a/music21/spanner.py b/music21/spanner.py
index 22e4a2c02..619b46173 100644
--- a/music21/spanner.py
+++ b/music21/spanner.py
@@ -32,6 +32,7 @@ from music21 import defaults
 from music21 import environment
 from music21 import exceptions21
 from music21 import prebase
+from music21 import sites
 from music21 import style
 
 environLocal = environment.Environment('spanner')
@@ -238,11 +239,27 @@ class Spanner(base.Music21Object):
 
         # parameters that spanners need in loading and processing
         # local id is the id for the local area; used by musicxml
-        self.idLocal = None
+        self.idLocal: str | None = None
         # after all spannedElements have been gathered, setting complete
         # will mark that all parts have been gathered.
         self.completeStatus = False
 
+        # data for fill:
+
+        # fillElementTypes is a list of types of object to search for.  This
+        # can be set to something different in the __init__ of a particular
+        # type of Spanner.
+        # Set here to the empty list, so that by default, fill() does nothing.
+        self.fillElementTypes: list[t.Type] = []
+
+        # After a fill operation, filledStatus will be set to True.
+        # Parsers and other clients can also set this to False or
+        # True to mark whether or not a fill operation is needed
+        # (False means fill is needed, True means fill is not
+        # needed, presumably because the fill was done by hand).
+        # Initialized to 'unknown'.
+        self.filledStatus: bool | t.Literal['unknown'] = 'unknown'
+
     def _reprInternal(self):
         msg = []
         for c in self.getSpannedElements():
@@ -529,6 +546,134 @@ class Spanner(base.Music21Object):
         # environLocal.printDebug(['replaceSpannedElement()', 'id(old)', id(old),
         #    'id(new)', id(new)])
 
+    def fill(
+        self,
+        searchStream=None,  # stream.Stream | None, but cannot import stream here
+        *,
+        includeEndBoundary: bool = False,
+        mustFinishInSpan: bool = False,
+        mustBeginInSpan: bool = True,
+        includeElementsThatEndAtStart: bool = False
+    ):
+        '''
+        Fills in the intermediate elements of a spanner, that are found in searchStream between
+        the first element's offset and the last element's offset+duration.  If searchStream
+        is None, the first element's activeSite is used.  If the first element's activeSite
+        is None, a SpannerException is raised.
+
+        Ottava is an example of a Spanner that can be filled. The Ottava does not need
+        to be inserted into the stream in order to be filled.
+
+        >>> m = stream.Measure([note.Note('A'), note.Note('B'), note.Note('C')])
+        >>> ott1 = spanner.Ottava(m.notes[0], m.notes[2])
+        >>> ott1.fill(m)
+        >>> ott1
+        <music21.spanner.Ottava 8va transposing<...Note A><...Note B><...Note C>>
+
+        If the searchStream is not passed in, fill still happens in this case, because
+        the first note's activeSite is used instead.
+
+        >>> ott2 = spanner.Ottava(m.notes[0], m.notes[2])
+        >>> ott2.fill()
+        >>> ott2
+        <music21.spanner.Ottava 8va transposing<...Note A><...Note B><...Note C>>
+
+        If the searchStream is not passed, and the spanner's first element doesn't have
+        an activeSite, a SpannerException is raised.
+
+        >>> ott3 = spanner.Ottava(note.Note('D'), note.Note('E'))
+        >>> ott3.fill()
+        Traceback (most recent call last):
+        music21.spanner.SpannerException: ...requires a searchStream or getFirst().activeSite
+        '''
+
+        if not self.fillElementTypes:
+            # nothing to fill
+            return
+
+        if self.filledStatus is True:
+            # Don't fill twice.  If client wants a refill they can set filledStatus to False.
+            return
+
+        startElement: base.Music21Object | None = self.getFirst()
+        if startElement is None:
+            # no spanned elements?  Nothing to fill.
+            return
+
+        if searchStream is None:
+            searchStream = startElement.activeSite
+            if searchStream is None:
+                raise SpannerException(
+                    'Spanner.fill() requires a searchStream or getFirst().activeSite'
+                )
+
+        if t.TYPE_CHECKING:
+            from music21 import stream
+            assert isinstance(searchStream, stream.Stream)
+
+        endElement: base.Music21Object | None = self.getLast()
+        if endElement is startElement:
+            endElement = None
+
+        if endElement is not None:
+            # Start and end elements are different; we can't just append everything, we need
+            # to save the end element, remove it, add everything, then add the end element
+            # again.  Note that if there are actually more than 2 elements before we start
+            # filling, the new intermediate elements will come after the existing ones,
+            # regardless of offset.  But first and last will still be the same two elements
+            # as before, which is the most important thing.
+            self.spannerStorage.remove(endElement)
+
+        try:
+            startOffsetInHierarchy: OffsetQL = startElement.getOffsetInHierarchy(searchStream)
+        except sites.SitesException:
+            # print('start element not in searchStream')
+            if endElement is not None:
+                self.addSpannedElements(endElement)
+            return
+
+        endOffsetInHierarchy: OffsetQL
+        if endElement is not None:
+            try:
+                endOffsetInHierarchy = (
+                    endElement.getOffsetInHierarchy(searchStream) + endElement.quarterLength
+                )
+            except sites.SitesException:
+                # print('end element not in searchStream')
+                self.addSpannedElements(endElement)
+                return
+        else:
+            endOffsetInHierarchy = (
+                startOffsetInHierarchy + startElement.quarterLength
+            )
+
+        matchIterator = (searchStream
+            .recurse()
+            .getElementsByOffsetInHierarchy(
+                startOffsetInHierarchy,
+                endOffsetInHierarchy,
+                includeEndBoundary=includeEndBoundary,
+                mustFinishInSpan=mustFinishInSpan,
+                mustBeginInSpan=mustBeginInSpan,
+                includeElementsThatEndAtStart=includeElementsThatEndAtStart)
+            .getElementsByClass(self.fillElementTypes)
+        )
+
+        for foundElement in matchIterator:
+            if foundElement is startElement:
+                # it's already in the spanner, skip it
+                continue
+            if foundElement is endElement:
+                # we'll add it below, skip it
+                continue
+            self.addSpannedElements(foundElement)
+
+        if endElement is not None:
+            # add it back in as the end element
+            self.addSpannedElements(endElement)
+
+        self.filledStatus = True
+
     def isFirst(self, spannedElement):
         '''
         Given a spannedElement, is it first?
@@ -1184,6 +1329,8 @@ class Slur(Spanner):
         super().__init__(*spannedElements, **keywords)
         self.placement = None  # can above or below, after musicxml
         self.lineType = None  # can be 'dashed' or None
+        # from music21 import note
+        # self.fillElementTypes = [note.NotRest]
 
     # TODO: add property for placement
 
@@ -1220,6 +1367,9 @@ class MultiMeasureRest(Spanner):
 
     def __init__(self, *spannedElements, **keywords):
         super().__init__(*spannedElements, **keywords)
+
+        # from music21 import note
+        # self.fillElementTypes = [note.Rest]
         self._overriddenNumber = None
         self.useSymbols = keywords.get('useSymbols', defaults.multiMeasureRestUseSymbols)
         self.maxSymbols = keywords.get('maxSymbols', defaults.multiMeasureRestMaxSymbols)
@@ -1356,6 +1506,10 @@ class RepeatBracket(Spanner):
                  overrideDisplay: str | None = None,
                  **keywords):
         super().__init__(*spannedElements, **keywords)
+
+        # from music21 import stream
+        # self.fillElementTypes = [stream.Measure]
+
         # store a range, inclusive of the single number assignment
         self.numberRange: list[int] = []
         self.overrideDisplay = overrideDisplay
@@ -1531,7 +1685,9 @@ class Ottava(Spanner):
                  transposing: bool = True,
                  placement: t.Literal['above', 'below'] = 'above',
                  **keywords):
+        from music21 import note
         super().__init__(*spannedElements, **keywords)
+        self.fillElementTypes = [note.NotRest]
         self._type = None  # can be 8va, 8vb, 15ma, 15mb
         self.type = type
 
@@ -1640,8 +1796,10 @@ class Ottava(Spanner):
     def performTransposition(self):
         '''
         On a transposing spanner, switch to non-transposing,
-        set style.hideObjectOnPrint to True, and transpose all notes and chords
-        in the spanner.  Called by :meth:`~music21.stream.Stream.toSoundingPitch` in Stream
+        and transpose all notes and chords in the spanner.
+        The note/chords will all be transposed to their sounding
+        pitch (at least as far as the ottava is concerned;
+        transposing instruments are handled separately).
 
         >>> ottava = spanner.Ottava(type='8va')
         >>> n1 = note.Note('D#4')
@@ -1660,7 +1818,6 @@ class Ottava(Spanner):
         if not self.transposing:
             return
         self.transposing = False
-        self.style.hideObjectOnPrint = True
 
         myInterval = self.interval()
         for n in self.getSpannedElements():
@@ -1672,10 +1829,10 @@ class Ottava(Spanner):
     def undoTransposition(self):
         '''
         Change a non-transposing spanner to a transposing spanner,
-        making sure it is not hidden and transpose back all the notes
-        and chords in the spanner.
-
-        Called by :meth:`~music21.stream.Stream.toWrittenPitch` in Stream
+        and transpose back all the notes and chords in the spanner.
+        The notes/chords will all be transposed to their written
+        pitch (at least as far as the ottava is concerned;
+        transposing instruments are handled separately).
 
         >>> ottava = spanner.Ottava(type='8va')
         >>> n1 = note.Note('D#4')
@@ -1693,7 +1850,6 @@ class Ottava(Spanner):
         if self.transposing:
             return
         self.transposing = True
-        self.style.hideObjectOnPrint = False
 
         myInterval = self.interval(reverse=True)
         for n in self.getSpannedElements():
@@ -1733,6 +1889,8 @@ class Line(Spanner):
         **keywords
     ):
         super().__init__(*spannedElements, **keywords)
+        # from music21 import note
+        # self.fillElementTypes = [note.GeneralNote]
 
         DEFAULT_TICK = 'down'
         self._endTick = DEFAULT_TICK  # can be up/down/arrow/both/None
@@ -1892,6 +2050,8 @@ class Glissando(Spanner):
                  label: str | None = None,
                  **keywords):
         super().__init__(*spannedElements, **keywords)
+        # from music21 import note
+        # self.fillElementTypes = [note.NotRest]
 
         GLISSANDO_DEFAULT_LINE_TYPE = 'wavy'
         self._lineType = GLISSANDO_DEFAULT_LINE_TYPE
@@ -2004,11 +2164,90 @@ class Test(unittest.TestCase):
 
         self.assertEqual(sl1.getSpannerSites(), [sp])
 
+    def testSpannerAnchorRepr(self):
+        from music21 import stream
+        from music21 import spanner
+
+        # SpannerAnchor with no activeSite
+        sa1 = spanner.SpannerAnchor()
+        self.assertEqual(repr(sa1), '<music21.spanner.SpannerAnchor unanchored>')
+
+        # SpannerAnchor with activeSite, but no duration
+        m = stream.Measure()
+        m.insert(0.5, sa1)
+        self.assertEqual(repr(sa1), '<music21.spanner.SpannerAnchor at 0.5>')
+
+        # SpannerAnchor with activeSite and duration
+        sa1.quarterLength = 2.5
+        self.assertEqual(repr(sa1), '<music21.spanner.SpannerAnchor at 0.5-3.0>')
+
     def testSpannerRepr(self):
         from music21 import spanner
         su1 = spanner.Slur()
         self.assertEqual(repr(su1), '<music21.spanner.Slur>')
 
+    def testSpannerFill(self):
+        from music21 import stream
+        from music21 import note
+        from music21 import spanner
+        theNotes = [note.Note('A'), note.Note('B'), note.Note('C'), note.Note('D')]
+        m = stream.Measure(theNotes)
+
+        # Spanner with no fillElementTypes
+        sp = spanner.Spanner(theNotes[0], theNotes[3])
+        sp.fill(m)
+        # should not have done anything
+        noFillElements = [theNotes[0], theNotes[3]]
+        self.assertEqual(len(sp), 2)
+        for i, el in enumerate(sp.getSpannedElements()):
+            self.assertIs(el, noFillElements[i])
+
+        # Ottava with filledStatus == True
+        ott1 = spanner.Ottava(noFillElements)
+        ott1.filledStatus = True  # pretend it has already been filled
+        ott1.fill(m)
+        # should not have done anything
+        self.assertEqual(len(sp), 2)
+        for i, el in enumerate(sp.getSpannedElements()):
+            self.assertIs(el, noFillElements[i])
+
+        # same Ottava but with filledStatus == False
+        ott1.filledStatus = False
+        ott1.fill(m)
+        # ott1 should have been filled
+        self.assertIs(ott1.filledStatus, True)
+        self.assertEqual(len(ott1), 4)
+        for i, el in enumerate(ott1.getSpannedElements()):
+            self.assertIs(el, theNotes[i])
+
+        # Ottava with no elements
+        ott2 = spanner.Ottava()
+        ott2.fill(m)
+        self.assertEqual(len(ott2), 0)
+
+        # Ottava with only element not in searchStream
+        expectedElements = [note.Note('E')]
+        ott3 = spanner.Ottava(expectedElements)
+        ott3.fill(m)
+        self.assertEqual(len(ott3), 1)
+        self.assertIs(ott3.getFirst(), expectedElements[0])
+
+        # Ottava with start element not in searchStream, end element is
+        expectedElements = [note.Note('F'), m.notes[0]]
+        ott4 = spanner.Ottava(expectedElements)
+        ott4.fill(m)
+        self.assertEqual(len(ott4), 2)
+        for i, el in enumerate(ott4.getSpannedElements()):
+            self.assertIs(el, expectedElements[i])
+
+        # Ottava with endElement not in searchStream, startElement is
+        expectedElements = [m.notes[0], note.Note('G')]
+        ott5 = spanner.Ottava(expectedElements)
+        ott5.fill(m)
+        self.assertEqual(len(ott5), 2)
+        for i, el in enumerate(ott5.getSpannedElements()):
+            self.assertIs(el, expectedElements[i])
+
     def testSpannerBundle(self):
         from music21 import spanner
         from music21 import stream
@@ -2797,6 +3036,29 @@ class Test(unittest.TestCase):
         slList = sl.getSpannedElementIds()
         self.assertEqual(idList, slList)
 
+    def testHammerOnPullOff(self):
+        from music21 import converter
+        from music21.musicxml import testPrimitive
+
+        s = converter.parse(testPrimitive.notations32a)
+
+        num_hammer_on = len(s.flatten().getElementsByClass('HammerOn'))
+        num_pull_off = len(s.flatten().getElementsByClass('PullOff'))
+        self.assertEqual(num_hammer_on, 1)
+        self.assertEqual(num_pull_off, 1)
+
+        hammer_on = s.flatten().getElementsByClass('HammerOn')[0]
+        hammer_n0 = hammer_on.getSpannedElements()[0]
+        hammer_n1 = hammer_on.getSpannedElements()[1]
+        self.assertEqual(hammer_n0.getSpannerSites()[0], hammer_on)
+        self.assertEqual(hammer_n1.getSpannerSites()[0], hammer_on)
+
+        pull_off = s.flatten().getElementsByClass('PullOff')[0]
+        pull_n0 = pull_off.getSpannedElements()[0]
+        pull_n1 = pull_off.getSpannedElements()[1]
+        self.assertEqual(pull_n0.getSpannerSites()[0], pull_off)
+        self.assertEqual(pull_n1.getSpannerSites()[0], pull_off)
+
 
 # ------------------------------------------------------------------------------
 # define presented order in documentation
diff --git a/music21/stream/__init__.py b/music21/stream/__init__.py
index a81195692..c6b96fa9d 100644
--- a/music21/stream/__init__.py
+++ b/music21/stream/__init__.py
@@ -24,3 +24,4 @@ from music21.stream import filters
 from music21.stream import iterator
 from music21.stream import makeNotation
 from music21.stream import streamStatus
+from music21.stream import tools
diff --git a/music21/stream/base.py b/music21/stream/base.py
index 7664b443d..d962975ac 100644
--- a/music21/stream/base.py
+++ b/music21/stream/base.py
@@ -55,6 +55,7 @@ from music21 import key
 from music21 import metadata
 from music21 import meter
 from music21 import note
+from music21 import pitch
 from music21 import tie
 from music21 import repeat
 from music21 import sites
@@ -70,7 +71,6 @@ from music21.stream.enums import GivenElementsBehavior, RecursionType, ShowNumbe
 
 
 if t.TYPE_CHECKING:
-    from music21 import pitch
     from music21 import spanner
 
 
@@ -4234,7 +4234,7 @@ class Stream(core.StreamCore, t.Generic[M21ObjType]):
     #
     #    TODO: write this
     #    '''
-    #    raise Exception('not yet implemented')
+    #    raise TypeError('not yet implemented')
     #
     #
     # def getElementBeforeElement(self, element, classList=None):
@@ -4243,7 +4243,7 @@ class Stream(core.StreamCore, t.Generic[M21ObjType]):
     #
     #    TODO: write this
     #    '''
-    #    raise Exception('not yet implemented')
+    #    raise TypeError('not yet implemented')
 
     def getElementAfterElement(self, element, classList=None):
         '''
@@ -5218,6 +5218,7 @@ class Stream(core.StreamCore, t.Generic[M21ObjType]):
         *,
         reverse: bool = False,
         transposeKeySignature: bool = True,
+        preserveAccidentalDisplay: bool = False,
         inPlace: t.Literal[True],
     ) -> None:
         pass
@@ -5228,6 +5229,7 @@ class Stream(core.StreamCore, t.Generic[M21ObjType]):
         *,
         reverse: bool = False,
         transposeKeySignature: bool = True,
+        preserveAccidentalDisplay: bool = False,
         inPlace: t.Literal[False] = False,
     ) -> StreamType:
         pass
@@ -5237,6 +5239,7 @@ class Stream(core.StreamCore, t.Generic[M21ObjType]):
         *,
         reverse: bool = False,
         transposeKeySignature: bool = True,
+        preserveAccidentalDisplay: bool = False,
         inPlace: bool = False,
     ) -> StreamType | None:
         '''
@@ -5273,6 +5276,8 @@ class Stream(core.StreamCore, t.Generic[M21ObjType]):
         else:
             classFilterList = (note.Note, chord.Chord)
 
+        displayStatusesAreSet: bool = self.haveAccidentalsBeenMade()
+
         for inst in instrument_stream:
             if inst.transposition is None:
                 continue
@@ -5287,9 +5292,12 @@ class Stream(core.StreamCore, t.Generic[M21ObjType]):
             trans = inst.transposition
             if reverse:
                 trans = trans.reverse()
-            focus.transpose(trans,
-                            inPlace=True,
-                            classFilterList=classFilterList)
+
+            if preserveAccidentalDisplay and displayStatusesAreSet:
+                with makeNotation.saveAccidentalDisplayStatus(focus):
+                    focus.transpose(trans, inPlace=True, classFilterList=classFilterList)
+            else:
+                focus.transpose(trans, inPlace=True, classFilterList=classFilterList)
 
         # restore original durations
         for inst, original_ql in instrument_map.items():
@@ -5326,7 +5334,7 @@ class Stream(core.StreamCore, t.Generic[M21ObjType]):
                     at_sounding = site.atSoundingPitch
                     break
             else:
-                raise StreamException('atSoundingPitch is unknown: cannot transpose')
+                return 'unknown'
 
         for substream in self.recurse(streamsOnly=True, includeSelf=False):
             if substream.atSoundingPitch == 'unknown':
@@ -5338,7 +5346,12 @@ class Stream(core.StreamCore, t.Generic[M21ObjType]):
 
         return at_sounding
 
-    def toSoundingPitch(self, *, inPlace=False):
+    def toSoundingPitch(
+        self,
+        *,
+        preserveAccidentalDisplay: bool = False,
+        inPlace=False
+    ):
         # noinspection PyShadowingNames
         '''
         If not at sounding pitch, transpose all Pitch
@@ -5371,23 +5384,26 @@ class Stream(core.StreamCore, t.Generic[M21ObjType]):
         'C3'
 
         If 'atSoundingPitch' is unknown for this Stream and all of its parent Streams
-        then will raise a StreamException:
+        then no transposition will take place, and atSoundingPitch will remain unknown
+        (this used to raise an exception):
 
         >>> s = stream.Score()
         >>> p = stream.Part(id='partEmpty')
-        >>> s.append(p)
+        >>> s.insert(0.0, p)
         >>> p.toSoundingPitch()
-        Traceback (most recent call last):
-        music21.exceptions21.StreamException: atSoundingPitch is unknown: cannot transpose
+        <music21.stream.Part partEmpty>
         >>> s.atSoundingPitch = False
         >>> sp = p.toSoundingPitch()
         >>> sp
         <music21.stream.Part partEmpty>
+        >>> sp.atSoundingPitch
+        'unknown'
         >>> sp.derivation.origin is p
         True
 
         * Changed in v2.0.10: inPlace is False
         * Changed in v5: returns None if inPlace=True
+        * Changed in v9: no transposition instead of exception if atSoundingPitch is 'unknown'
         '''
         from music21 import spanner
 
@@ -5399,7 +5415,10 @@ class Stream(core.StreamCore, t.Generic[M21ObjType]):
         if returnObj.hasPartLikeStreams() or 'Opus' in returnObj.classSet:
             for partLike in returnObj.getElementsByClass(Stream):
                 # call on each part
-                partLike.toSoundingPitch(inPlace=True)
+                partLike.toSoundingPitch(
+                    inPlace=True,
+                    preserveAccidentalDisplay=preserveAccidentalDisplay
+                )
             returnObj.atSoundingPitch = True
             return returnObj if not inPlace else None
 
@@ -5407,7 +5426,11 @@ class Stream(core.StreamCore, t.Generic[M21ObjType]):
 
         if at_sounding is False:
             # transposition defined on instrument goes from written to sounding
-            returnObj._transposeByInstrument(reverse=False, inPlace=True)
+            returnObj._transposeByInstrument(
+                reverse=False,
+                preserveAccidentalDisplay=preserveAccidentalDisplay,
+                inPlace=True
+            )
             for container in returnObj.recurse(streamsOnly=True, includeSelf=True):
                 container.atSoundingPitch = True
 
@@ -5417,11 +5440,23 @@ class Stream(core.StreamCore, t.Generic[M21ObjType]):
         if not inPlace:
             return returnObj  # the Stream or None
 
-    def toWrittenPitch(self, *, inPlace=False):
+    def toWrittenPitch(
+        self,
+        *,
+        ottavasToSounding: bool = False,
+        preserveAccidentalDisplay: bool = False,
+        inPlace: bool = False
+    ):
         '''
         If not at written pitch, transpose all Pitch elements to
         written pitch. The atSoundingPitch property is used to
-        determine if transposition is necessary.
+        determine if transposition is necessary.  Note that if
+        ottavasToSounding is True, any notes/chords within
+        an Ottava will _then_ be transposed to sounding Pitch (this
+        is useful for the MusicXML writer, since MusicXML likes
+        all pitches to be written pitches, except for those in
+        ottavas, which should be transposed to written (by instrument)
+        and then transposed to sounding (by ottava).
 
         >>> sc = stream.Score()
         >>> p = stream.Part(id='baritoneSax')
@@ -5446,6 +5481,7 @@ class Stream(core.StreamCore, t.Generic[M21ObjType]):
 
         * Changed in v3: `inPlace` defaults to `False`
         * Changed in v5 returns `None` if `inPlace=True`
+        * Changed in v9: no transposition instead of exception if atSoundingPitch is 'unknown'
         '''
         from music21 import spanner
 
@@ -5457,20 +5493,32 @@ class Stream(core.StreamCore, t.Generic[M21ObjType]):
         if returnObj.hasPartLikeStreams() or 'Opus' in returnObj.classes:
             for partLike in returnObj.getElementsByClass('Stream'):
                 # call on each part
-                partLike.toWrittenPitch(inPlace=True)
+                if t.TYPE_CHECKING:
+                    assert isinstance(partLike, Stream)
+                partLike.toWrittenPitch(
+                    inPlace=True,
+                    ottavasToSounding=ottavasToSounding,
+                    preserveAccidentalDisplay=preserveAccidentalDisplay
+                )
             returnObj.atSoundingPitch = False
-            return returnObj if not inPlace else None
-
-        at_sounding = returnObj._treatAtSoundingPitch()
-
-        if at_sounding is True:
-            # need to reverse to go to written
-            returnObj._transposeByInstrument(reverse=True, inPlace=True)
-            for container in returnObj.recurse(streamsOnly=True, includeSelf=True):
-                container.atSoundingPitch = False
+        else:
+            at_sounding = returnObj._treatAtSoundingPitch()
+            if at_sounding is True:
+                # need to reverse to go to written
+                returnObj._transposeByInstrument(
+                    reverse=True,
+                    preserveAccidentalDisplay=preserveAccidentalDisplay,
+                    inPlace=True
+                )
+                for container in returnObj.recurse(streamsOnly=True, includeSelf=True):
+                    container.atSoundingPitch = False
 
-        for ottava in returnObj[spanner.Ottava]:
-            ottava.undoTransposition()
+        if ottavasToSounding:
+            for ottava in returnObj[spanner.Ottava]:
+                ottava.performTransposition()
+        else:
+            for ottava in returnObj[spanner.Ottava]:
+                ottava.undoTransposition()
 
         if not inPlace:
             return returnObj
@@ -8968,10 +9016,16 @@ class Stream(core.StreamCore, t.Generic[M21ObjType]):
         else:
             post = self
 
-        intv: interval.IntervalBase
+        intv: interval.Interval | interval.GenericInterval
         if isinstance(value, (int, str)):
             intv = interval.Interval(value)
+        elif isinstance(value, interval.ChromaticInterval):
+            intv = interval.Interval(chromatic=value)
+        elif isinstance(value, interval.DiatonicInterval):
+            intv = interval.Interval(diatonic=value)
         else:
+            if t.TYPE_CHECKING:
+                assert isinstance(value, (interval.GenericInterval, interval.Interval))
             intv = value
 
         # for p in post.pitches:  # includes chords
@@ -9003,7 +9057,8 @@ class Stream(core.StreamCore, t.Generic[M21ObjType]):
                         for p in e.pitches:  # type: ignore
                             intv.transposePitchKeyAware(p, k, inPlace=True)
                 else:
-                    e.transpose(value, inPlace=True)
+                    e.transpose(intv, inPlace=True)
+
         if not inPlace:
             return post
         else:
diff --git a/music21/stream/makeNotation.py b/music21/stream/makeNotation.py
index 0bf12508e..14a76a2be 100644
--- a/music21/stream/makeNotation.py
+++ b/music21/stream/makeNotation.py
@@ -14,6 +14,7 @@
 from __future__ import annotations
 
 from collections.abc import Iterable, Generator
+import contextlib
 import copy
 import typing as t
 import unittest
@@ -2063,6 +2064,38 @@ def consolidateCompletedTuplets(
                     last_tuplet = None
                     completion_target = None
 
+@contextlib.contextmanager
+def saveAccidentalDisplayStatus(s) -> t.Generator[None, None, None]:
+    '''
+    Restore accidental displayStatus on a Stream after an (inPlace) operation
+    that sets accidental displayStatus (e.g. a transposition).  Note that you
+    should not do this unless you know that the displayStatus values will still
+    be valid after the operation.
+
+        classList = (key.KeySignature, note.Note, chord.Chord)
+        with saveAccidentalDisplayStatus(s):
+            m.transpose(intv, inPlace=True, classFilterList=classList)
+
+    * New in v9.
+    '''
+    displayStatuses: dict[int, bool | None] = {}
+    for p in s.pitches:
+        if p.accidental is not None:
+            displayStatuses[id(p)] = p.accidental.displayStatus
+            continue
+        displayStatuses[id(p)] = False
+
+    try:
+        yield
+    finally:
+        for p in s.pitches:
+            if p.accidental is not None:
+                p.accidental.displayStatus = displayStatuses.get(id(p), None)
+                continue
+            if displayStatuses.get(id(p), False) is True:
+                p.accidental = pitch.Accidental(0)
+                p.accidental.displayStatus = True
+
 
 # -----------------------------------------------------------------------------
 
@@ -2245,6 +2278,28 @@ class Test(unittest.TestCase):
         self.assertEqual(len(pp[stream.Measure][2].notes), 1)
         self.assertEqual(pp[stream.Measure][2].notes.first().duration.quarterLength, 24.0)
 
+    def testSaveAccidentalDisplayStatus(self):
+        from music21 import interval
+        from music21 import stream
+        m = stream.Measure([key.Key('C'), note.Note('C2'), note.Note('D2')])
+        m.notes[0].pitch.accidental = 0
+        m.notes[0].pitch.accidental.displayStatus = True
+        m.notes[1].pitch.accidental = 0
+        m.notes[1].pitch.accidental.displayStatus = False
+        classList = (note.Note, chord.Chord, key.KeySignature)
+        with saveAccidentalDisplayStatus(m):
+            m.transpose(interval.Interval('m2'), inPlace=True, classFilterList=classList)
+            self.assertEqual(m.notes[0].nameWithOctave, 'D-2')
+            self.assertIsNone(m.notes[0].pitch.accidental.displayStatus)
+            self.assertEqual(m.notes[1].nameWithOctave, 'E-2')
+            self.assertIsNone(m.notes[1].pitch.accidental.displayStatus)
+
+        # After exiting the with statement, accidental displayStatus will have been restored
+        self.assertEqual(m.notes[0].nameWithOctave, 'D-2')
+        self.assertIs(m.notes[0].pitch.accidental.displayStatus, True)
+        self.assertEqual(m.notes[1].nameWithOctave, 'E-2')
+        self.assertIs(m.notes[1].pitch.accidental.displayStatus, False)
+
 
 # -----------------------------------------------------------------------------
 
diff --git a/music21/stream/tools.py b/music21/stream/tools.py
new file mode 100644
index 000000000..86f5c2844
--- /dev/null
+++ b/music21/stream/tools.py
@@ -0,0 +1,203 @@
+# -*- coding: utf-8 -*-
+# ------------------------------------------------------------------------------
+# Name:         stream/tools.py
+# Purpose:      Additional tools for working with streams
+#
+# Authors:      Mark Gotham
+#
+# Copyright:    Copyright © 2022 Michael Scott Asato Cuthbert
+# License:      BSD, see license.txt
+# ------------------------------------------------------------------------------
+
+from __future__ import annotations
+import typing as t
+
+from music21 import clef
+from music21 import environment
+from music21 import key
+from music21 import meter
+from music21 import stream
+
+if t.TYPE_CHECKING:
+    from music21.base import Music21Object
+
+environLocal = environment.Environment('stream.tools')
+
+
+# ------------------------------------------------------------------------------
+
+def removeDuplicates(thisStream: stream.Stream,
+                     classesToRemove: tuple = (meter.TimeSignature, key.KeySignature, clef.Clef),
+                     inPlace: bool = True
+                     ) -> stream.Stream:
+    '''
+    The repetition of some classes like notes is common.
+    By contrast, the explicit repetition of certain other objects like clefs
+    usually indicates an error e.g., resulting from a copy'n'paste.
+    This function serves to remove those that are likely in error and make no change.
+
+    Use the `classesToRemove` argument to specify which music21 classes to check and remove.
+    The classes currently supported are: time signatures, key signatures, and clefs.
+    The `classesToRemove` argument defaults all three.
+    Sometimes there are legitimate reasons to duplicate even these classes.
+    In that case, override the default by specifying the list of which of the three of classes.
+    More classes may be added, but for now they will simply raise a ValueError.
+
+    So let's create an example part with an initial set of
+    time signature, key signature, and clef.
+
+    >>> p = stream.Part()
+    >>> p.append(meter.TimeSignature('3/4'))  # first TS
+    >>> p.append(key.KeySignature(6))  # first KS
+    >>> p.append(clef.TrebleClef())  # first Clef
+
+    Then a few notes, followed by a duplicates of the
+    key signature, and clef.
+
+    >>> p.append(note.Note('C'))
+    >>> p.append(note.Note('C'))
+    >>> p.append(note.Note('D'))
+
+    >>> p.append(meter.TimeSignature('3/4'))  # duplicate
+    >>> p.append(key.KeySignature(6))  # duplicate
+    >>> p.append(clef.TrebleClef())  # duplicate
+
+    Finally, a few more notes, followed by a
+    change of time signature, key signature, and clef.
+
+    >>> p.append(note.Note('E'))
+    >>> p.append(note.Note('F'))
+    >>> p.append(note.Note('G'))
+
+    >>> p.append(meter.TimeSignature('2/4'))
+    >>> p.append(key.KeySignature(-5))
+    >>> p.append(clef.BassClef())
+
+    >>> p.append(note.Note('A'))
+    >>> p.append(note.Note('B'))
+    >>> p.append(note.Note('C5'))
+
+    Now we'll make it into a proper part with measures and see
+    how it looks in its original, unaltered form:
+
+    >>> p.makeMeasures(inPlace=True)
+    >>> p.show('t')
+    {0.0} <music21.stream.Measure 1 offset=0.0>
+        {0.0} <music21.clef.TrebleClef>
+        {0.0} <music21.key.KeySignature of 6 sharps>
+        {0.0} <music21.meter.TimeSignature 3/4>
+        {0.0} <music21.note.Note C>
+        {1.0} <music21.note.Note C>
+        {2.0} <music21.note.Note D>
+    {3.0} <music21.stream.Measure 2 offset=3.0>
+        {0.0} <music21.clef.TrebleClef>
+        {0.0} <music21.key.KeySignature of 6 sharps>
+        {0.0} <music21.meter.TimeSignature 3/4>
+        {0.0} <music21.note.Note E>
+        {1.0} <music21.note.Note F>
+        {2.0} <music21.note.Note G>
+    {6.0} <music21.stream.Measure 3 offset=6.0>
+        {0.0} <music21.clef.BassClef>
+        {0.0} <music21.key.KeySignature of 5 flats>
+        {0.0} <music21.meter.TimeSignature 2/4>
+        {0.0} <music21.note.Note A>
+        {1.0} <music21.note.Note B>
+    {8.0} <music21.stream.Measure 4 offset=8.0>
+        {0.0} <music21.note.Note C>
+        {1.0} <music21.bar.Barline type=final>
+
+    Calling removeDuplicates should remove the duplicates
+    even with those changes now stored within measures,
+    not directly on the part.
+    Specifically, in our example,
+    the duplicates entries are removed from measure 2
+    and the actual changes in measure 3 remain.
+
+    >>> testInPlace = stream.tools.removeDuplicates(p)
+    >>> testInPlace.show('t')
+    {0.0} <music21.stream.Measure 1 offset=0.0>
+        {0.0} <music21.clef.TrebleClef>
+        {0.0} <music21.key.KeySignature of 6 sharps>
+        {0.0} <music21.meter.TimeSignature 3/4>
+        {0.0} <music21.note.Note C>
+        {1.0} <music21.note.Note C>
+        {2.0} <music21.note.Note D>
+    {3.0} <music21.stream.Measure 2 offset=3.0>
+        {0.0} <music21.note.Note E>
+        {1.0} <music21.note.Note F>
+        {2.0} <music21.note.Note G>
+    {6.0} <music21.stream.Measure 3 offset=6.0>
+        {0.0} <music21.clef.BassClef>
+        {0.0} <music21.key.KeySignature of 5 flats>
+        {0.0} <music21.meter.TimeSignature 2/4>
+        {0.0} <music21.note.Note A>
+        {1.0} <music21.note.Note B>
+    {8.0} <music21.stream.Measure 4 offset=8.0>
+        {0.0} <music21.note.Note C>
+        {1.0} <music21.bar.Barline type=final>
+
+    As the example shows, this function defaults to working on a stream inPlace.
+
+    >>> testInPlace == p
+    True
+
+    To avoid this, set inPlace to False.
+
+    >>> testNotInPlace = stream.tools.removeDuplicates(p, inPlace=False)
+    >>> testNotInPlace == p
+    False
+
+    This function is primarily designed and intended for use on a stream.Part object.
+    If called on a steam.Score, it will simply be applied to each of that score's parts in turn.
+
+    >>> s = stream.Score()
+    >>> s.append(p)
+    >>> t = stream.tools.removeDuplicates(s, inPlace=False)
+    >>> s.parts[0] == testInPlace
+    True
+
+    '''
+
+    supportedClasses = (meter.TimeSignature, key.KeySignature, clef.Clef)
+
+    removalDict: dict[stream.Stream, list[Music21Object]] = {}
+
+    if not inPlace:
+        thisStream = thisStream.coreCopyAsDerivation('removeDuplicates')
+
+    if isinstance(thisStream, stream.Score):
+        if len(thisStream.parts) > 0:
+            for p in thisStream.parts:
+                removeDuplicates(p, classesToRemove=classesToRemove, inPlace=True)
+
+    for thisClass in classesToRemove:
+
+        if not any(issubclass(thisClass, supportedClass) for supportedClass in supportedClasses):
+            raise ValueError(f'Invalid class. Only {supportedClasses} are supported.')
+
+        allStates = thisStream.recurse().getElementsByClass(thisClass)
+
+        if len(allStates) < 2:  # Not used, or doesn't change
+            continue
+
+        currentState = allStates[0]  # First to initialize: can't be a duplicate
+        for thisState in allStates[1:]:
+            if thisState == currentState:
+                if thisState.activeSite in removalDict:  # May be several in same (e.g., measure)
+                    removalDict[thisState.activeSite].append(thisState)
+                else:
+                    removalDict[thisState.activeSite] = [thisState]
+            else:
+                currentState = thisState
+
+    for activeSiteKey, valuesToRemove in removalDict.items():
+        activeSiteKey.remove(valuesToRemove, recurse=True)
+
+    return thisStream
+
+
+# -----------------------------------------------------------------------------
+
+if __name__ == '__main__':
+    import music21
+    music21.mainTest()
diff --git a/music21/text.py b/music21/text.py
index a4c21b275..ad58f53f6 100644
--- a/music21/text.py
+++ b/music21/text.py
@@ -123,7 +123,7 @@ def assembleLyrics(streamIn, lineNumber=1):
                     words.append(''.join(word))
                     word = []
             else:
-                raise Exception(f'no known Text syllabic setting: {lyricObj.syllabic}')
+                raise ValueError(f'no known Text syllabic setting: {lyricObj.syllabic}')
     return ' '.join(words)
 
 
diff --git a/music21/voiceLeading.py b/music21/voiceLeading.py
index 3005f2a4b..4eab14ee6 100644
--- a/music21/voiceLeading.py
+++ b/music21/voiceLeading.py
@@ -191,7 +191,7 @@ class VoiceLeadingQuartet(base.Music21Object):
         if isinstance(keyValue, str):
             try:
                 keyValue = key.Key(key.convertKeyStringToMusic21KeyString(keyValue))
-            except Exception as e:  # pragma: no cover  # pylint: disable=broad-except
+            except Exception as e:  # pragma: no cover  # pylint: disable=broad-exception-caught
                 raise VoiceLeadingQuartetException(
                     f'got a key signature string that is not supported: {keyValue}'
                 ) from e
@@ -225,7 +225,7 @@ class VoiceLeadingQuartet(base.Music21Object):
                     n.duration.quarterLength = 0.0
                     n.pitch = value
                     setattr(self, which, n)
-            except Exception as e:  # pragma: no cover  # pylint: disable=broad-except
+            except Exception as e:  # pragma: no cover  # pylint: disable=broad-exception-caught
                 raise VoiceLeadingQuartetException(
                     f'not a valid note specification: {value!r}'
                 ) from e
diff --git a/requirements_dev.txt b/requirements_dev.txt
index e27661757..020c243f4 100644
--- a/requirements_dev.txt
+++ b/requirements_dev.txt
@@ -1,5 +1,5 @@
 -r requirements.txt
-pylint>=2.15.4
+pylint>=2.16.0
 flake8<6.0.0
 flake8-quotes
 hatchling


Test patch:
diff --git a/documentation/source/developerReference/musicxmlTest.ipynb b/documentation/source/developerReference/musicxmlTest.ipynb
index 932b9349a..a54178336 100644
--- a/documentation/source/developerReference/musicxmlTest.ipynb
+++ b/documentation/source/developerReference/musicxmlTest.ipynb
@@ -71,7 +71,7 @@
     "            if i in shortFp:\n",
     "                break\n",
     "        else:\n",
-    "            raise Exception(\"Cannot find: \" + str(i))\n",
+    "            raise FileNotFoundError(\"Cannot find: \" + str(i))\n",
     "    shortFp = longFp.name \n",
     "    if shortFp in untested:\n",
     "        untested.remove(shortFp)\n",
@@ -6485,4 +6485,4 @@
  },
  "nbformat": 4,
  "nbformat_minor": 1
-}
\ No newline at end of file
+}
diff --git a/documentation/testDocumentation.py b/documentation/testDocumentation.py
index 521fffa0b..49b540d77 100644
--- a/documentation/testDocumentation.py
+++ b/documentation/testDocumentation.py
@@ -238,7 +238,7 @@ def main(runOne: str | bool = False):
                 print(f'all {testCount} tests ran successfully')
             totalTests += testCount
             totalFailures += failCount
-        except Exception as e:  # pylint: disable=broad-except
+        except Exception as e:  # pylint: disable=broad-exception-caught
             print(f'failed miserably! {e}')
             import traceback
             tb = traceback.format_exc()
diff --git a/music21/musicxml/testPrimitive.py b/music21/musicxml/testPrimitive.py
index 2ad084766..2e1674a60 100644
--- a/music21/musicxml/testPrimitive.py
+++ b/music21/musicxml/testPrimitive.py
@@ -19047,6 +19047,985 @@ multipleFingeringsOnChord = '''
   </score-partwise>
 '''
 
+pianoStaffWithOttava = '''
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE score-partwise  PUBLIC "-//Recordare//DTD MusicXML 4.0 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
+<score-partwise version="4.0">
+  <work>
+    <work-title>Ottava example from Piano Sonata no. 7 in D major</work-title>
+  </work>
+  <identification>
+    <creator type="composer">Beethoven, Ludwig van</creator>
+    <encoding>
+      <encoding-date>2023-01-31</encoding-date>
+      <software>music21 v.9.0.0a8</software>
+    </encoding>
+  </identification>
+  <defaults>
+    <scaling>
+      <millimeters>7</millimeters>
+      <tenths>40</tenths>
+    </scaling>
+  </defaults>
+  <part-list>
+    <score-part id="Pb8c49cf3a7c0a9788ffa21045c504eb5">
+      <part-name>Piano</part-name>
+      <part-abbreviation>Pno</part-abbreviation>
+      <score-instrument id="Iae4d13d8fe088971bdc57585144cb786">
+        <instrument-name>Piano</instrument-name>
+        <instrument-abbreviation>Pno</instrument-abbreviation>
+      </score-instrument>
+      <midi-instrument id="Iae4d13d8fe088971bdc57585144cb786">
+        <midi-channel>1</midi-channel>
+        <midi-program>1</midi-program>
+      </midi-instrument>
+    </score-part>
+  </part-list>
+  <!--=========================== Part 1 ===========================-->
+  <part id="Pb8c49cf3a7c0a9788ffa21045c504eb5">
+    <!--========================= Measure 63 =========================-->
+    <measure implicit="no" number="63">
+      <attributes>
+        <divisions>10080</divisions>
+        <key>
+          <fifths>-1</fifths>
+          <mode>minor</mode>
+        </key>
+        <time>
+          <beats>6</beats>
+          <beat-type>8</beat-type>
+        </time>
+        <staves>2</staves>
+        <clef number="1">
+          <sign>G</sign>
+          <line>2</line>
+        </clef>
+        <clef number="2">
+          <sign>G</sign>
+          <line>2</line>
+        </clef>
+      </attributes>
+      <direction placement="above">
+        <direction-type>
+          <words />
+        </direction-type>
+        <staff>1</staff>
+        <sound tempo="40" />
+      </direction>
+      <direction placement="above">
+        <direction-type>
+          <octave-shift number="1" size="8" type="down" />
+        </direction-type>
+        <staff>1</staff>
+      </direction>
+      <note>
+        <pitch>
+          <step>E</step>
+          <alter>-1</alter>
+          <octave>5</octave>
+        </pitch>
+        <duration>7560</duration>
+        <voice>1</voice>
+        <type>eighth</type>
+        <dot />
+        <accidental>flat</accidental>
+        <staff>1</staff>
+        <beam number="1">begin</beam>
+        <notations>
+          <slur number="1" placement="below" type="start" />
+        </notations>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>E</step>
+          <alter>-1</alter>
+          <octave>6</octave>
+        </pitch>
+        <duration>7560</duration>
+        <voice>1</voice>
+        <type>eighth</type>
+        <dot />
+        <accidental>flat</accidental>
+        <staff>1</staff>
+      </note>
+      <note>
+        <pitch>
+          <step>D</step>
+          <octave>5</octave>
+        </pitch>
+        <duration>2520</duration>
+        <voice>1</voice>
+        <type>16th</type>
+        <staff>1</staff>
+        <beam number="1">continue</beam>
+        <beam number="2">begin</beam>
+        <notations>
+          <slur number="1" type="stop" />
+        </notations>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>D</step>
+          <octave>6</octave>
+        </pitch>
+        <duration>2520</duration>
+        <voice>1</voice>
+        <type>16th</type>
+        <staff>1</staff>
+      </note>
+      <note>
+        <pitch>
+          <step>C</step>
+          <alter>0</alter>
+          <octave>5</octave>
+        </pitch>
+        <duration>2520</duration>
+        <voice>1</voice>
+        <type>16th</type>
+        <accidental>natural</accidental>
+        <staff>1</staff>
+        <beam number="1">continue</beam>
+        <beam number="2">continue</beam>
+        <notations>
+          <articulations>
+            <staccato placement="below" />
+          </articulations>
+        </notations>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>C</step>
+          <alter>0</alter>
+          <octave>6</octave>
+        </pitch>
+        <duration>2520</duration>
+        <voice>1</voice>
+        <type>16th</type>
+        <accidental>natural</accidental>
+        <staff>1</staff>
+      </note>
+      <note>
+        <pitch>
+          <step>E</step>
+          <alter>-1</alter>
+          <octave>5</octave>
+        </pitch>
+        <duration>2520</duration>
+        <voice>1</voice>
+        <type>16th</type>
+        <staff>1</staff>
+        <beam number="1">end</beam>
+        <beam number="2">end</beam>
+        <notations>
+          <articulations>
+            <staccato placement="below" />
+          </articulations>
+        </notations>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>E</step>
+          <alter>-1</alter>
+          <octave>6</octave>
+        </pitch>
+        <duration>2520</duration>
+        <voice>1</voice>
+        <type>16th</type>
+        <staff>1</staff>
+      </note>
+      <note>
+        <pitch>
+          <step>F</step>
+          <alter>0</alter>
+          <octave>5</octave>
+        </pitch>
+        <duration>7560</duration>
+        <voice>1</voice>
+        <type>eighth</type>
+        <dot />
+        <accidental>natural</accidental>
+        <staff>1</staff>
+        <beam number="1">begin</beam>
+        <notations>
+          <slur number="2" placement="below" type="start" />
+        </notations>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>F</step>
+          <alter>0</alter>
+          <octave>6</octave>
+        </pitch>
+        <duration>7560</duration>
+        <voice>1</voice>
+        <type>eighth</type>
+        <dot />
+        <accidental>natural</accidental>
+        <staff>1</staff>
+      </note>
+      <note>
+        <pitch>
+          <step>E</step>
+          <alter>0</alter>
+          <octave>5</octave>
+        </pitch>
+        <duration>2520</duration>
+        <voice>1</voice>
+        <type>16th</type>
+        <accidental>natural</accidental>
+        <staff>1</staff>
+        <beam number="1">continue</beam>
+        <beam number="2">begin</beam>
+        <notations>
+          <slur number="2" type="stop" />
+        </notations>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>E</step>
+          <alter>0</alter>
+          <octave>6</octave>
+        </pitch>
+        <duration>2520</duration>
+        <voice>1</voice>
+        <type>16th</type>
+        <accidental>natural</accidental>
+        <staff>1</staff>
+      </note>
+      <note>
+        <pitch>
+          <step>D</step>
+          <octave>5</octave>
+        </pitch>
+        <duration>2520</duration>
+        <voice>1</voice>
+        <type>16th</type>
+        <staff>1</staff>
+        <beam number="1">continue</beam>
+        <beam number="2">continue</beam>
+        <notations>
+          <articulations>
+            <staccato placement="below" />
+          </articulations>
+        </notations>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>D</step>
+          <octave>6</octave>
+        </pitch>
+        <duration>2520</duration>
+        <voice>1</voice>
+        <type>16th</type>
+        <staff>1</staff>
+      </note>
+      <note>
+        <pitch>
+          <step>F</step>
+          <octave>5</octave>
+        </pitch>
+        <duration>2520</duration>
+        <voice>1</voice>
+        <type>16th</type>
+        <staff>1</staff>
+        <beam number="1">end</beam>
+        <beam number="2">end</beam>
+        <notations>
+          <articulations>
+            <staccato placement="below" />
+          </articulations>
+        </notations>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>F</step>
+          <octave>6</octave>
+        </pitch>
+        <duration>2520</duration>
+        <voice>1</voice>
+        <type>16th</type>
+        <staff>1</staff>
+      </note>
+      <backup>
+        <duration>30240</duration>
+      </backup>
+      <direction placement="below">
+        <direction-type>
+          <dynamics valign="middle">
+            <other-dynamics>ffp</other-dynamics>
+          </dynamics>
+        </direction-type>
+        <staff>1</staff>
+        <sound dynamics="63" />
+      </direction>
+      <note>
+        <pitch>
+          <step>F</step>
+          <alter>1</alter>
+          <octave>5</octave>
+        </pitch>
+        <duration>15120</duration>
+        <voice>2</voice>
+        <type>quarter</type>
+        <dot />
+        <accidental>sharp</accidental>
+        <staff>1</staff>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>A</step>
+          <octave>5</octave>
+        </pitch>
+        <duration>15120</duration>
+        <voice>2</voice>
+        <type>quarter</type>
+        <dot />
+        <staff>1</staff>
+      </note>
+      <direction placement="below">
+        <direction-type>
+          <dynamics valign="middle">
+            <other-dynamics>ffp</other-dynamics>
+          </dynamics>
+        </direction-type>
+        <staff>1</staff>
+        <sound dynamics="63" />
+      </direction>
+      <note>
+        <pitch>
+          <step>G</step>
+          <alter>1</alter>
+          <octave>5</octave>
+        </pitch>
+        <duration>15120</duration>
+        <voice>2</voice>
+        <type>quarter</type>
+        <dot />
+        <accidental>sharp</accidental>
+        <staff>1</staff>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>B</step>
+          <alter>0</alter>
+          <octave>5</octave>
+        </pitch>
+        <duration>15120</duration>
+        <voice>2</voice>
+        <type>quarter</type>
+        <dot />
+        <accidental>natural</accidental>
+        <staff>1</staff>
+      </note>
+      <backup>
+        <duration>30240</duration>
+      </backup>
+      <note>
+        <pitch>
+          <step>F</step>
+          <alter>1</alter>
+          <octave>4</octave>
+        </pitch>
+        <duration>15120</duration>
+        <voice>3</voice>
+        <type>quarter</type>
+        <dot />
+        <accidental>sharp</accidental>
+        <staff>2</staff>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>A</step>
+          <octave>4</octave>
+        </pitch>
+        <duration>15120</duration>
+        <voice>3</voice>
+        <type>quarter</type>
+        <dot />
+        <staff>2</staff>
+      </note>
+      <note>
+        <pitch>
+          <step>G</step>
+          <alter>1</alter>
+          <octave>4</octave>
+        </pitch>
+        <duration>15120</duration>
+        <voice>3</voice>
+        <type>quarter</type>
+        <dot />
+        <accidental>sharp</accidental>
+        <staff>2</staff>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>B</step>
+          <alter>0</alter>
+          <octave>4</octave>
+        </pitch>
+        <duration>15120</duration>
+        <voice>3</voice>
+        <type>quarter</type>
+        <dot />
+        <accidental>natural</accidental>
+        <staff>2</staff>
+      </note>
+      <backup>
+        <duration>30240</duration>
+      </backup>
+      <note>
+        <pitch>
+          <step>C</step>
+          <alter>0</alter>
+          <octave>4</octave>
+        </pitch>
+        <duration>7560</duration>
+        <voice>4</voice>
+        <type>eighth</type>
+        <dot />
+        <accidental>natural</accidental>
+        <staff>2</staff>
+        <beam number="1">begin</beam>
+        <notations>
+          <slur number="3" type="start" />
+        </notations>
+      </note>
+      <note>
+        <pitch>
+          <step>D</step>
+          <octave>4</octave>
+        </pitch>
+        <duration>2520</duration>
+        <voice>4</voice>
+        <type>16th</type>
+        <staff>2</staff>
+        <beam number="1">continue</beam>
+        <beam number="2">begin</beam>
+        <notations>
+          <slur number="3" type="stop" />
+        </notations>
+      </note>
+      <note>
+        <pitch>
+          <step>E</step>
+          <alter>-1</alter>
+          <octave>4</octave>
+        </pitch>
+        <duration>2520</duration>
+        <voice>4</voice>
+        <type>16th</type>
+        <accidental>flat</accidental>
+        <staff>2</staff>
+        <beam number="1">continue</beam>
+        <beam number="2">continue</beam>
+        <notations>
+          <articulations>
+            <staccato placement="above" />
+          </articulations>
+        </notations>
+      </note>
+      <note>
+        <pitch>
+          <step>A</step>
+          <octave>3</octave>
+        </pitch>
+        <duration>2520</duration>
+        <voice>4</voice>
+        <type>16th</type>
+        <staff>2</staff>
+        <beam number="1">end</beam>
+        <beam number="2">end</beam>
+        <notations>
+          <articulations>
+            <staccato placement="above" />
+          </articulations>
+        </notations>
+      </note>
+      <note>
+        <pitch>
+          <step>D</step>
+          <octave>4</octave>
+        </pitch>
+        <duration>7560</duration>
+        <voice>4</voice>
+        <type>eighth</type>
+        <dot />
+        <staff>2</staff>
+        <beam number="1">begin</beam>
+        <notations>
+          <slur number="4" type="start" />
+        </notations>
+      </note>
+      <note>
+        <pitch>
+          <step>E</step>
+          <alter>0</alter>
+          <octave>4</octave>
+        </pitch>
+        <duration>2520</duration>
+        <voice>4</voice>
+        <type>16th</type>
+        <accidental>natural</accidental>
+        <staff>2</staff>
+        <beam number="1">continue</beam>
+        <beam number="2">begin</beam>
+        <notations>
+          <slur number="4" type="stop" />
+        </notations>
+      </note>
+      <note>
+        <pitch>
+          <step>F</step>
+          <alter>0</alter>
+          <octave>4</octave>
+        </pitch>
+        <duration>2520</duration>
+        <voice>4</voice>
+        <type>16th</type>
+        <accidental>natural</accidental>
+        <staff>2</staff>
+        <beam number="1">continue</beam>
+        <beam number="2">continue</beam>
+        <notations>
+          <articulations>
+            <staccato placement="above" />
+          </articulations>
+        </notations>
+      </note>
+      <note>
+        <pitch>
+          <step>D</step>
+          <octave>4</octave>
+        </pitch>
+        <duration>2520</duration>
+        <voice>4</voice>
+        <type>16th</type>
+        <staff>2</staff>
+        <beam number="1">end</beam>
+        <beam number="2">end</beam>
+        <notations>
+          <articulations>
+            <staccato placement="above" />
+          </articulations>
+        </notations>
+      </note>
+      <barline location="right">
+        <bar-style>regular</bar-style>
+      </barline>
+    </measure>
+    <!--========================= Measure 64 =========================-->
+    <measure implicit="no" number="64">
+      <note>
+        <pitch>
+          <step>C</step>
+          <alter>1</alter>
+          <octave>6</octave>
+        </pitch>
+        <duration>5040</duration>
+        <voice>1</voice>
+        <type>eighth</type>
+        <accidental>sharp</accidental>
+        <staff>1</staff>
+      </note>
+      <note>
+        <rest />
+        <duration>5040</duration>
+        <voice>1</voice>
+        <type>eighth</type>
+        <staff>1</staff>
+      </note>
+      <note print-object="no" print-spacing="yes">
+        <rest />
+        <duration>15120</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <dot />
+        <staff>1</staff>
+      </note>
+      <note print-object="no" print-spacing="yes">
+        <rest />
+        <duration>5040</duration>
+        <voice>1</voice>
+        <type>eighth</type>
+        <staff>1</staff>
+      </note>
+      <note print-object="no" print-spacing="yes">
+        <rest />
+        <duration>5040</duration>
+        <voice>1</voice>
+        <type>eighth</type>
+        <staff>1</staff>
+      </note>
+      <note print-object="no" print-spacing="yes">
+        <rest />
+        <duration>5040</duration>
+        <voice>1</voice>
+        <type>eighth</type>
+        <staff>1</staff>
+      </note>
+      <backup>
+        <duration>40320</duration>
+      </backup>
+      <note>
+        <pitch>
+          <step>E</step>
+          <octave>5</octave>
+        </pitch>
+        <duration>5040</duration>
+        <voice>2</voice>
+        <type>eighth</type>
+        <staff>1</staff>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>G</step>
+          <alter>0</alter>
+          <octave>5</octave>
+        </pitch>
+        <duration>5040</duration>
+        <voice>2</voice>
+        <type>eighth</type>
+        <accidental>natural</accidental>
+        <staff>1</staff>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>B</step>
+          <alter>-1</alter>
+          <octave>5</octave>
+        </pitch>
+        <duration>5040</duration>
+        <voice>2</voice>
+        <type>eighth</type>
+        <accidental>flat</accidental>
+        <staff>1</staff>
+      </note>
+      <direction placement="above">
+        <direction-type>
+          <octave-shift number="1" size="8" type="stop" />
+        </direction-type>
+        <staff>1</staff>
+      </direction>
+      <note>
+        <rest />
+        <duration>5040</duration>
+        <voice>2</voice>
+        <type>eighth</type>
+        <staff>1</staff>
+      </note>
+      <direction placement="below">
+        <direction-type>
+          <dynamics valign="middle">
+            <p />
+          </dynamics>
+        </direction-type>
+        <staff>1</staff>
+        <sound dynamics="44" />
+      </direction>
+      <note>
+        <pitch>
+          <step>D</step>
+          <octave>5</octave>
+        </pitch>
+        <duration>5040</duration>
+        <voice>2</voice>
+        <type>eighth</type>
+        <staff>1</staff>
+        <beam number="1">begin</beam>
+        <notations>
+          <slur number="5" type="start" />
+          <articulations>
+            <staccato />
+          </articulations>
+        </notations>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>F</step>
+          <octave>5</octave>
+        </pitch>
+        <duration>5040</duration>
+        <voice>2</voice>
+        <type>eighth</type>
+        <staff>1</staff>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>A</step>
+          <octave>5</octave>
+        </pitch>
+        <duration>5040</duration>
+        <voice>2</voice>
+        <type>eighth</type>
+        <staff>1</staff>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>D</step>
+          <octave>6</octave>
+        </pitch>
+        <duration>5040</duration>
+        <voice>2</voice>
+        <type>eighth</type>
+        <staff>1</staff>
+      </note>
+      <note>
+        <pitch>
+          <step>B</step>
+          <alter>-1</alter>
+          <octave>4</octave>
+        </pitch>
+        <duration>5040</duration>
+        <voice>2</voice>
+        <type>eighth</type>
+        <staff>1</staff>
+        <beam number="1">end</beam>
+        <notations>
+          <slur number="5" type="stop" />
+          <articulations>
+            <staccato />
+          </articulations>
+        </notations>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>D</step>
+          <octave>5</octave>
+        </pitch>
+        <duration>5040</duration>
+        <voice>2</voice>
+        <type>eighth</type>
+        <staff>1</staff>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>E</step>
+          <octave>5</octave>
+        </pitch>
+        <duration>5040</duration>
+        <voice>2</voice>
+        <type>eighth</type>
+        <staff>1</staff>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>B</step>
+          <alter>-1</alter>
+          <octave>5</octave>
+        </pitch>
+        <duration>5040</duration>
+        <voice>2</voice>
+        <type>eighth</type>
+        <staff>1</staff>
+      </note>
+      <note>
+        <rest />
+        <duration>10080</duration>
+        <voice>2</voice>
+        <type>quarter</type>
+        <staff>1</staff>
+      </note>
+      <backup>
+        <duration>30240</duration>
+      </backup>
+      <note>
+        <pitch>
+          <step>C</step>
+          <alter>1</alter>
+          <octave>5</octave>
+        </pitch>
+        <duration>5040</duration>
+        <voice>3</voice>
+        <type>eighth</type>
+        <accidental>sharp</accidental>
+        <staff>2</staff>
+      </note>
+      <note>
+        <rest />
+        <duration>5040</duration>
+        <voice>3</voice>
+        <type>eighth</type>
+        <staff>2</staff>
+      </note>
+      <note>
+        <pitch>
+          <step>F</step>
+          <octave>4</octave>
+        </pitch>
+        <duration>5040</duration>
+        <voice>3</voice>
+        <type>eighth</type>
+        <staff>2</staff>
+        <beam number="1">begin</beam>
+        <notations>
+          <slur number="6" placement="below" type="start" />
+          <articulations>
+            <staccato placement="below" />
+          </articulations>
+        </notations>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>A</step>
+          <octave>4</octave>
+        </pitch>
+        <duration>5040</duration>
+        <voice>3</voice>
+        <type>eighth</type>
+        <staff>2</staff>
+      </note>
+      <note>
+        <pitch>
+          <step>G</step>
+          <octave>3</octave>
+        </pitch>
+        <duration>5040</duration>
+        <voice>3</voice>
+        <type>eighth</type>
+        <staff>2</staff>
+        <beam number="1">end</beam>
+        <notations>
+          <slur number="6" type="stop" />
+          <articulations>
+            <staccato placement="below" />
+          </articulations>
+        </notations>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>D</step>
+          <octave>4</octave>
+        </pitch>
+        <duration>5040</duration>
+        <voice>3</voice>
+        <type>eighth</type>
+        <staff>2</staff>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>E</step>
+          <octave>4</octave>
+        </pitch>
+        <duration>5040</duration>
+        <voice>3</voice>
+        <type>eighth</type>
+        <staff>2</staff>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>G</step>
+          <octave>4</octave>
+        </pitch>
+        <duration>5040</duration>
+        <voice>3</voice>
+        <type>eighth</type>
+        <staff>2</staff>
+      </note>
+      <note>
+        <rest />
+        <duration>10080</duration>
+        <voice>3</voice>
+        <type>quarter</type>
+        <staff>2</staff>
+      </note>
+      <backup>
+        <duration>30240</duration>
+      </backup>
+      <note>
+        <pitch>
+          <step>E</step>
+          <octave>4</octave>
+        </pitch>
+        <duration>5040</duration>
+        <voice>4</voice>
+        <type>eighth</type>
+        <staff>2</staff>
+        <notations>
+          <articulations>
+            <staccato />
+          </articulations>
+        </notations>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>G</step>
+          <alter>0</alter>
+          <octave>4</octave>
+        </pitch>
+        <duration>5040</duration>
+        <voice>4</voice>
+        <type>eighth</type>
+        <accidental>natural</accidental>
+        <staff>2</staff>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>B</step>
+          <alter>-1</alter>
+          <octave>4</octave>
+        </pitch>
+        <duration>5040</duration>
+        <voice>4</voice>
+        <type>eighth</type>
+        <accidental>flat</accidental>
+        <staff>2</staff>
+      </note>
+      <note>
+        <rest />
+        <duration>5040</duration>
+        <voice>4</voice>
+        <type>eighth</type>
+        <staff>2</staff>
+      </note>
+      <note print-object="no" print-spacing="yes">
+        <rest />
+        <duration>15120</duration>
+        <voice>4</voice>
+        <type>quarter</type>
+        <dot />
+        <staff>2</staff>
+      </note>
+      <note print-object="no" print-spacing="yes">
+        <rest />
+        <duration>5040</duration>
+        <voice>4</voice>
+        <type>eighth</type>
+        <staff>2</staff>
+      </note>
+      <barline location="right">
+        <bar-style>light-heavy</bar-style>
+      </barline>
+    </measure>
+  </part>
+</score-partwise>
+'''
+
 ALL = [
     articulations01, pitches01a, directions31a, lyricsMelisma61d, notations32a,  # 0
     restsDurations02a, rhythmDurations03a, chordsThreeNotesDuration21c,  # 5
@@ -19064,7 +20043,8 @@ ALL = [
     unicodeStrNoNonAscii, unicodeStrWithNonAscii,  # 44
     tremoloTest, hiddenRests, multiDigitEnding, tupletsImplied, pianoStaffPolymeter,  # 46
     arpeggio32d, multiStaffArpeggios, multiMeasureEnding,   # 51
-    pianoStaffPolymeterWithClefOctaveChange, multipleFingeringsOnChord  # 54
+    pianoStaffPolymeterWithClefOctaveChange, multipleFingeringsOnChord,  # 54
+    pianoStaffWithOttava  # 56
 ]
 
 
diff --git a/music21/musicxml/test_m21ToXml.py b/music21/musicxml/test_m21ToXml.py
index b43ff96c2..8605dc433 100644
--- a/music21/musicxml/test_m21ToXml.py
+++ b/music21/musicxml/test_m21ToXml.py
@@ -607,6 +607,13 @@ class Test(unittest.TestCase):
         n = note.Note()
         a = articulations.StringIndication()
         n.articulations.append(a)
+        h = articulations.HammerOn()
+        # Appending a spanner such as HammerOn to the articulations
+        # array is contrary to the documentation and contrary to the
+        # behavior of the musicxml importer, but we're testing it here
+        # anyway to just ensure that *IF* a user decides to do this themselves,
+        # we don't create a superfluous <other-technical> tag on export.
+        n.articulations.append(h)
 
         # Legal values for StringIndication begin at 1
         self.assertEqual(a.number, 0)
@@ -614,6 +621,7 @@ class Test(unittest.TestCase):
         gex = GeneralObjectExporter(n)
         tree = et_fromstring(gex.parse().decode('utf-8'))
         self.assertIsNone(tree.find('.//string'))
+        self.assertIsNone(tree.find('.//other-technical'))
 
     def testMeasurePadding(self):
         s = stream.Score([converter.parse('tinyNotation: 4/4 c4')])
diff --git a/music21/musicxml/test_xmlToM21.py b/music21/musicxml/test_xmlToM21.py
index d2a56724e..035e42c52 100644
--- a/music21/musicxml/test_xmlToM21.py
+++ b/music21/musicxml/test_xmlToM21.py
@@ -166,6 +166,26 @@ class Test(unittest.TestCase):
         lastOffset = lastMeasure.elementOffset(lastElement, returnSpecial=True)
         self.assertEqual(lastOffset, 'highestTime')
 
+    def testMultipleStavesInPartWithOttava(self):
+        from music21 import converter
+        from music21.musicxml import testPrimitive
+        s = converter.parse(testPrimitive.pianoStaffWithOttava)
+        self.assertEqual(len(s.getElementsByClass(stream.PartStaff)), 2)
+        ps0 = s[stream.PartStaff][0]
+        self.assertEqual(len(ps0.getElementsByClass(spanner.Ottava)), 1)
+        m0 = ps0[stream.Measure][0]
+        self.assertEqual(
+            [p.nameWithOctave for p in m0.pitches],
+            ['E-5', 'E-6', 'D5', 'D6', 'C5', 'C6', 'E-5', 'E-6', 'F5', 'F6', 'E5', 'E6',
+             'D5', 'D6', 'F5', 'F6', 'F#5', 'A5', 'G#5', 'B5']
+        )
+        s.toWrittenPitch(inPlace=True)
+        self.assertEqual(
+            [p.nameWithOctave for p in m0.pitches],
+            ['E-4', 'E-5', 'D4', 'D5', 'C4', 'C5', 'E-4', 'E-5', 'F4', 'F5', 'E4', 'E5',
+             'D4', 'D5', 'F4', 'F5', 'F#4', 'A4', 'G#4', 'B4']
+        )
+
     def testSpannersA(self):
         from music21 import converter
         from music21.musicxml import testPrimitive
@@ -590,7 +610,7 @@ class Test(unittest.TestCase):
             for e in n.expressions:
                 if 'Turn' in e.classes:
                     count += 1
-        self.assertEqual(count, 4)  # include inverted turn
+        self.assertEqual(count, 5)  # include inverted turn
 
         count = 0
         for n in s.recurse().notes:
diff --git a/music21/stream/tests.py b/music21/stream/tests.py
index 5916923c4..1d662b80b 100644
--- a/music21/stream/tests.py
+++ b/music21/stream/tests.py
@@ -4570,14 +4570,11 @@ class Test(unittest.TestCase):
                           ('E#', True), ('E#', False), ('F#', False), ('F#', False)]
                          )
 
-        # transposing should reset all transposed accidentals
         mStream.flatten().transpose('p5', inPlace=True)
 
         # mStream.show()
 
-        # after transposition all accidentals are reset
-        # note: last d# is not showing in Finale, but this seems to be a
-        # finale error, as the musicxml is the same in all D# cases
+        self.maxDiff = None
         self.assertEqual(collectAccidentalDisplayStatus(mStream),
                          ['x', ('G#', None), ('G#', None), 'x', 'x', 'x', 'x',
                           ('B#', None), ('B#', None), ('C#', None), ('F#', None), ('G#', None),
@@ -7411,18 +7408,38 @@ class Test(unittest.TestCase):
         self.assertEqual(str(s.parts[1].getElementsByClass(instrument.Instrument)[0].transposition),
                          '<music21.interval.Interval M-6>')
 
+        # Set each part's first note's natural to be visible, to test that it will remain so
+        # after transposition
+        for p in s.parts:
+            firstPitch = p.pitches[0]
+            self.assertIsNone(firstPitch.accidental)
+            firstPitch.accidental = 0
+            firstPitch.accidental.displayStatus = True
+
         self.assertEqual([str(p) for p in s.parts[0].pitches],
                          ['D4', 'E4', 'F#4', 'G4', 'A4', 'B4', 'C#5', 'D5'])
+        self.assertEqual([None if p.accidental is None else p.accidental.displayStatus
+                            for p in s.parts[0].pitches],
+                         [True, None, False, None, None, None, False, None])
         self.assertEqual([str(p) for p in s.parts[1].pitches],
                          ['A4', 'B4', 'C#5', 'D5', 'E5', 'F#5', 'G#5', 'A5'])
+        self.assertEqual([None if p.accidental is None else p.accidental.displayStatus
+                            for p in s.parts[1].pitches],
+                         [True, None, False, None, None, False, False, None])
 
         self.assertEqual(s.atSoundingPitch, 'unknown')
-        s.toSoundingPitch(inPlace=True)
+        s.toSoundingPitch(inPlace=True, preserveAccidentalDisplay=True)
 
         self.assertEqual([str(p) for p in s.parts[0].pitches],
                          ['C4', 'D4', 'E4', 'F4', 'G4', 'A4', 'B4', 'C5'])
+        self.assertEqual([None if p.accidental is None else p.accidental.displayStatus
+                            for p in s.parts[0].pitches],
+                         [True, None, None, None, None, None, None, None])
         self.assertEqual([str(p) for p in s.parts[1].pitches],
                          ['C4', 'D4', 'E4', 'F4', 'G4', 'A4', 'B4', 'C5'])
+        self.assertEqual([None if p.accidental is None else p.accidental.displayStatus
+                            for p in s.parts[1].pitches],
+                         [True, None, None, None, None, None, None, None])
 
     def testTransposeByPitchC(self):
         p = converter.parse('tinyNotation: c1 d1')
diff --git a/music21/test/commonTest.py b/music21/test/commonTest.py
index e8f2fb663..e67f5b320 100644
--- a/music21/test/commonTest.py
+++ b/music21/test/commonTest.py
@@ -55,11 +55,11 @@ def testCopyAll(testInstance: unittest.TestCase, globals_: typing.Dict[str, typi
 
         try:
             copy.copy(instance)
-        except Exception as e:  # pylint: disable=broad-except
+        except Exception as e:  # pylint: disable=broad-exception-caught
             testInstance.fail(f'Could not copy obj {part}: {e}')
         try:
             copy.deepcopy(instance)
-        except Exception as e:  # pylint: disable=broad-except
+        except Exception as e:  # pylint: disable=broad-exception-caught
             testInstance.fail(f'Could not deepcopy obj {part}: {e}')
 
 
@@ -423,7 +423,7 @@ class ModuleGather:
         try:
             with warnings.catch_warnings():
                 mod = load_source(name, fp)
-        except Exception as excp:  # pylint: disable=broad-except
+        except Exception as excp:  # pylint: disable=broad-exception-caught
             environLocal.warn(['failed import:', name, '\t', fp, '\n',
                                '\tEXCEPTION:', str(excp).strip()])
             return None
diff --git a/music21/test/multiprocessTest.py b/music21/test/multiprocessTest.py
index 350ad5c1a..40aa6fff5 100644
--- a/music21/test/multiprocessTest.py
+++ b/music21/test/multiprocessTest.py
@@ -117,7 +117,7 @@ def runOneModuleWithoutImp(args):
                                   failures=failures,
                                   testsRun=testResult.testsRun,
                                   runTime=runTime)
-        except Exception as excp:  # pylint: disable=broad-except
+        except Exception as excp:  # pylint: disable=broad-exception-caught
             environLocal.printDebug(f'*** Exception in running {moduleName}: {excp}...\n')
             return ModuleResponse(returnCode='TrappedException',
                                   fp=fp,
@@ -125,7 +125,7 @@ def runOneModuleWithoutImp(args):
                                   success=None,
                                   testRunner=str(excp)
                                   )
-    except Exception as excp:  # pylint: disable=broad-except
+    except Exception as excp:  # pylint: disable=broad-exception-caught
         environLocal.printDebug(f'*** Large Exception in running {fp}: {excp}...\n')
         return ModuleResponse(returnCode='LargeException',
                               fp=fp,
@@ -210,7 +210,7 @@ def mainPoolRunner(testGroup=('test',), restoreEnvironmentDefaults=False, leaveO
                 continueIt = False
                 pool.close()
                 pool.join()
-            except Exception as excp:  # pylint: disable=broad-except
+            except Exception as excp:  # pylint: disable=broad-exception-caught
                 eventsProcessed += 1
                 exceptionLog = ModuleResponse(
                     returnCode='UntrappedException',
diff --git a/music21/test/test_expressions.py b/music21/test/test_expressions.py
index bdcdc6735..a800d216c 100644
--- a/music21/test/test_expressions.py
+++ b/music21/test/test_expressions.py
@@ -66,6 +66,8 @@ class Test(unittest.TestCase):
         self.assertEqual(re.getTextExpression().content, 'd.s. al fine')
 
     def testExpandTurns(self):
+        # n1 is a half-note with a delayed turn
+        # n1 is a dotted-quarter-note with a non-delayed inverted turn
         p1 = stream.Part()
         m1 = stream.Measure()
         m2 = stream.Measure()
@@ -73,7 +75,7 @@ class Test(unittest.TestCase):
         p1.append(key.Key('F', 'major'))
         p1.append(meter.TimeSignature('2/4'))
         n1 = note.Note('C5', type='half')
-        turn0 = expressions.Turn()
+        turn0 = expressions.Turn(delay=1.)  # delay is a quarter-note
         n1.expressions.append(turn0)
         n2 = note.Note('B4', type='quarter')
         n2.duration.dots = 1
@@ -88,16 +90,33 @@ class Test(unittest.TestCase):
         realized1 = expressions.realizeOrnaments(n1)
         realized2 = expressions.realizeOrnaments(n2)
         self.assertEqual('C5 D5 C5 B-4 C5', ' '.join(n.pitch.nameWithOctave for n in realized1))
-        self.assertEqual('B4 A#4 B4 C#5 B4', ' '.join(n.pitch.nameWithOctave for n in realized2))
+        self.assertEqual('A#4 B4 C#5 B4', ' '.join(n.pitch.nameWithOctave for n in realized2))
+
+        # total length of realized1 is half-note (ql=2.0): quarter note (half the original note)
+        # followed by 4 turn notes that together add up to the other quarter note.
         self.assertEqual(realized1[0].quarterLength, 1.0)
         self.assertEqual(realized1[1].quarterLength, 0.25)
-        self.assertEqual(realized2[0].quarterLength, 0.5)
+        self.assertEqual(realized1[2].quarterLength, 0.25)
+        self.assertEqual(realized1[3].quarterLength, 0.25)
+        self.assertEqual(realized1[4].quarterLength, 0.25)
+
+        # Because turn0.quarterLength defaults to 0.25 (16th-note), the first three turn
+        # notes are ql=0.25, and the fourth turn note has the remaining duration (0.75)
+        self.assertEqual(realized2[0].quarterLength, 0.25)
         self.assertEqual(realized2[1].quarterLength, 0.25)
+        self.assertEqual(realized2[2].quarterLength, 0.25)
+        self.assertEqual(realized2[3].quarterLength, 0.75)
 
+        # Here, delay is still a quarter-note (delay=1.0), the first three turn notes
+        # are ql=0.125 (because turn0.quarterLength has been set to 0.125), and the
+        # fourth turn note has the remaining duration (0.625)
         turn0.quarterLength = 0.125
         realized1b = expressions.realizeOrnaments(n1)
-        self.assertEqual(realized1b[0].quarterLength, 1.5)
+        self.assertEqual(realized1b[0].quarterLength, 1.0)
         self.assertEqual(realized1b[1].quarterLength, 0.125)
+        self.assertEqual(realized1b[2].quarterLength, 0.125)
+        self.assertEqual(realized1b[3].quarterLength, 0.125)
+        self.assertEqual(realized1b[4].quarterLength, 0.625)
 
 
     def testExpandTrills(self):
diff --git a/music21/test/treeYield.py b/music21/test/treeYield.py
index cb09d5ade..89b2a76b0 100644
--- a/music21/test/treeYield.py
+++ b/music21/test/treeYield.py
@@ -90,7 +90,7 @@ class TreeYielder:  # pragma: no cover
                 # noinspection PyBroadException
                 try:
                     gotValue = object.__getattribute__(obj, x)
-                except Exception:  # pylint: disable=broad-except
+                except Exception:  # pylint: disable=broad-exception-caught
                     continue
                 objTuple = ('getattr', x)
                 self.stackVals.append(objTuple)
@@ -98,7 +98,7 @@ class TreeYielder:  # pragma: no cover
                     for z in self.run(gotValue, memo=memo):
                         yield z
                 except RuntimeError:
-                    raise Exception(f'Maximum recursion on:\n{self.currentLevel()}')
+                    raise ValueError(f'Maximum recursion on:\n{self.currentLevel()}')
                 self.stackVals.pop()
 
         self.currentStack.pop()
@@ -116,7 +116,7 @@ class TreeYielder:  # pragma: no cover
             elif stackType == 'getattr':
                 currentStr += ".__getattribute__('" + stackValue + "')"
             else:
-                raise Exception(f'Cannot get attribute of type {stackType}')
+                raise ValueError(f'Cannot get attribute for stackType {stackType!r}')
         return currentStr