# ------------------------------------------------------------------------------
# Name:          HumdrumToken.py
# Purpose:       Represents a single field in a line of a Humdrum file
#                The intersection of line and spine, if you will...
#
# Authors:       Greg Chapman <gregc@mac.com>
#                Humdrum code derived/translated from humlib (authored by
#                       Craig Stuart Sapp <craig@ccrma.stanford.edu>)
#
# Copyright:     (c) 2021-2023 Greg Chapman
# License:       MIT, see LICENSE
# ------------------------------------------------------------------------------
import sys
import typing as t
import re
import copy
from fractions import Fraction

from music21.common import opFrac

from converter21.humdrum import HumAddress
from converter21.humdrum import HumdrumInternalError
from converter21.humdrum import HumNum, HumNumIn
from converter21.humdrum import HumHash
from converter21.humdrum import HumParamSet
from converter21.humdrum import Convert

# For debug or unit test print, a simple way to get a string which is the current function name
# with a colon appended.
# for current func name, specify 0 or no argument.
# for name of caller of current func, specify 1.
# for name of caller of caller of current func, specify 2. etc.
# pylint: disable=protected-access
funcName = lambda n=0: sys._getframe(n + 1).f_code.co_name + ':'  # pragma no cover
# pylint: enable=protected-access

# spine manipulators:
SPLIT_TOKEN: str = '*^'
MERGE_TOKEN: str = '*v'
EXCHANGE_TOKEN: str = '*x'
TERMINATE_TOKEN: str = '*-'
ADD_TOKEN: str = '*+'
# Also exclusive interpretations which start with '**' followed by the data type.

# other special tokens:
NULL_DATA: str = '.'
NULL_INTERPRETATION: str = '*'
NULL_COMMENT_LOCAL: str = '!'
NULL_COMMENT_GLOBAL: str = '!!'

# regex patterns
KEY_DESIGNATION_PATTERN: str = r'^\*([a-gA-G][#-]*):([a-z]{3})?'

# stria stuff
DEFAULT_LINES_PER_STAFF: int = 5
MAX_LINES_PER_STAFF: int = 32

def makeTag(string: str, num: int) -> str:
    if num > 1:
        return string + str(num)
    return string

# used to generate invisible rests to fill gaps.  Not a real HumdrumToken, but can be put
# in arrays of layerTokens, and will be specially treated.
class FakeRestToken(HumHash):
    # some attributes so we can be treated (a bit) like a real HumdrumToken
    isFakeRest: bool = True  # HumdrumToken.isFakeRest == False
    isBarline: bool = False
    isExclusiveInterpretation: bool = False

    def __init__(self, duration: HumNumIn, durationFromBarline: HumNumIn = 0) -> None:
        super().__init__()  # initialize the HumHash fields
        self.duration: HumNum = opFrac(duration)
        self.durationFromBarline: HumNum = opFrac(durationFromBarline)

# We use conditionally defined attributes to implement property caching, so make pylint shut up
# about it
# pylint: disable=attribute-defined-outside-init

class HumdrumToken(HumHash):
    isFakeRest: bool = False  # FakeRestToken.isFakeRest == True

    # phrase vs. slur
    PHRASE: str = '{'
    SLUR: str = '('

    def __init__(self, token: str = '') -> None:
        super().__init__()  # initialize the HumHash fields

        '''
            _text: the token's text string
        '''
        self._text: str = token

        self._subtokens: list[str] = []
        self._subtokensGenerated: bool = False

        '''
        // address: The address contains information about the location of
        // the token on a HumdrumLine and in a HumdrumFile.
        '''
        self._address: HumAddress = HumAddress()


        '''
        // duration: The duration of the token.  Non-rhythmic data types
        // will have a negative duration (which should be interpreted
        // as a zero duration--See HumdrumToken::hasRhythm()).
        // Grace note will have a zero duration, even if they have a duration
        // list in the token for a graphical display duration.
        '''
        self._duration: HumNum = opFrac(-1)

        # graceVisualDuration: only set for grace notes that have recip in their token
        # e.g. '16qqcc'.  self._duration is 0 (because it's a grace note), but the grace
        # note will have two flags because of the '16'.
        self._graceVisualDuration: HumNum = opFrac(-1)

        '''
        // nextTokens: This is a list of all previous tokens in the spine which
        // immediately follow this token. Typically there will be one
        // following token, but there can be two tokens if the current
        // token is *^, and there will be zero following tokens after a
        // spine terminating token (*-).
        '''
        self._nextTokens: list[HumdrumToken] = []

        # contains nextToken(0), for speed
        self._nextToken0: HumdrumToken | None = None

        '''
        // previousTokens: Simiar to nextTokens, but for the immediately
        // preceding token(s) in the data.  Typically there will be one
        // preceding token, but there can be multiple tokens when the previous
        // line has *v merge tokens for the spine.  Exclusive interpretations
        // have no tokens preceding them.
        '''
        self._previousTokens: list[HumdrumToken] = []

        # contains previousToken(0), for speed
        self._previousToken0: HumdrumToken | None = None

        '''
        // nextNonNullDataTokens: This is a list of non-null tokens in the spine
        // that follow this one.
        '''
        self._nextNonNullDataTokens: list[HumdrumToken] = []

        '''
        // previousNonNullDataTokens: This is a list of non-null tokens in the spine
        // that precede this one.
        '''
        self._previousNonNullDataTokens: list[HumdrumToken] = []

        '''
        // rhycheck: Used to perform HumdrumFileStructure::analyzeRhythm
        // recursively.
        '''
        self._rhythmAnalysisState: int = 0

        '''
        // strand: Used to keep track of contiguous voice connections between
        // secondary spines/tracks.  This is the 1-D strand index number
        // (not the 2-d one).
        '''
        self._strandIndex: int = -1

        '''
        // m_nullresolve: used to point to the token that a null token
        // refers to.
        '''
        self._nullResolution: HumdrumToken | None = None

        '''
        // m_linkedParameterTokens: List of Humdrum tokens which are parameters
        // (mostly only layout parameters at the moment).
        '''
        self._linkedParameterTokens: list[HumdrumToken] = []

        '''
        // m_parameterSet: A single parameter encoded in the text of the
        // token.  Was previously called m_linkedParameter.
        '''
        self._parameterSet: HumParamSet | None = None

        '''
        // m_rhythm_analyzed: Set to true when HumdrumFile assigned duration
        '''
        self._rhythmAnalyzed: bool = False

        '''
        // m_strophe: Starting point of a strophe that the token belongs to.
        // NULL means that it is not in a strophe.
        '''
        self._strophe: HumdrumToken | None = None

        '''
            rscale: the rscale that applies to this token
        '''
        self._rscale: HumNum = opFrac(1)

        '''
            We are also a HumHash, and that was initialized via super() above.
            But we want our default HumHash prefix to be '!', not ''
        '''
        self.prefix: str = '!'

        self._atLeastOneCachedTokenTextPropertyExists: bool = False
        self._atLeastOneCachedDataTypePropertyExists: bool = False

    # In C++ a HumdrumToken is also a string().  Deriving a mutable class from an immutable
    # base class in Python is trickier than I can manage. So we have a standard "conversion"
    # to string instead.  print("hdToken =", hdToken) will call this automatically, for example.
    # But most clients will need to call str(hdToken), or hdToken.text to "cast" to string.
    def __str__(self) -> str:
        return self.text

    # Now the actual HumdrumToken APIs/properties

    cachedTokenTextProperties: set[str] = {
        '_isDataCached',
        '_isInterpretationCached',
        '_isNonNullDataCached',
        '_isNullDataCached',
        '_isNullCached',
        '_isBarlineCached',
        '_isCommentCached',
        '_isLocalCommentCached',
        '_isGlobalCommentCached',
        '_isLabelCached',
        '_isChordCached',
        '_isExclusiveInterpretationCached',
        '_isSplitInterpretationCached',
        '_isMergeInterpretationCached',
        '_isExchangeInterpretationCached',
        '_isTerminateInterpretationCached',
        '_isAddInterpretationCached',
        '_isManipulatorCached',
        '_isRecipOnlyCached',
        '_hasBeamCached',
        '_hasFermataCached',
        '_isStaffInterpretationCached',
        '_isPartCached',
        '_isGroupCached',
        '_isRestCached',
        '_isNoteCached'
    }

    cachedDataTypeProperties: set[str] = {
        '_isKernCached',
        '_isRecipCached',
        '_isMensCached',
        '_hasRhythmCached',
        '_isStaffDataTypeCached',
        '_isRestCached',
        '_isNoteCached'
    }

    # Note that _isRestCached/_isNoteCached is in both lists, since it is a TokenText property,
    # but it also is parsed differently for different dataTypes.
    def _clearCachedTokenTextProperties(self) -> None:
        # we delete these attributes instead of setting them to None, to
        # avoid the cost of clearing cached properties during __init__
        # (the attributes do not exist, by default)
        if self._atLeastOneCachedTokenTextPropertyExists:
            for attrib in self.cachedTokenTextProperties:
                self.__dict__.pop(attrib, None)
            self._atLeastOneCachedTokenTextPropertyExists = False

    def _clearCachedDataTypeProperties(self) -> None:
        # we delete these attributes instead of setting them to None, to
        # avoid the cost of clearing cached properties during __init__
        # (the attributes do not exist, by default)
        if self._atLeastOneCachedDataTypePropertyExists:
            for attrib in self.cachedDataTypeProperties:
                self.__dict__.pop(attrib, None)
            self._atLeastOneCachedDataTypePropertyExists = False

    '''
    //////////////////////////////
    //
    // HumdrumToken::getText --
    '''
    @property
    def text(self) -> str:
        return self._text

    @text.setter
    def text(self, newText: str) -> None:
        self._text = newText
        self._subtokensGenerated = False
        if self._atLeastOneCachedTokenTextPropertyExists:
            self._clearCachedTokenTextProperties()

    '''
    //////////////////////////////
    //
    // HumdrumToken::getPreviousNonNullDataTokenCount -- Returns the number of
    //   previous tokens in the spine which is not a null token.  For null
    //   tokens, this will be a count of the number of non-null tokens which
    //   the null represents.
    // @SEEALSO: getPreviousNonNullDataToken
    '''
    @property
    def previousNonNullDataTokenCount(self) -> int:
        return len(self._previousNonNullDataTokens)

    @property
    def previousNonNullDataTokens(self) -> list['HumdrumToken']:
        return self._previousNonNullDataTokens

    '''
    //////////////////////////////
    //
    // HumdrumToken::insertTokenAfter -- Insert the given token after this token.
    //    This will sever the link from this token to its next token.  There is only
    //    presumed to be one next token, at least for the moment.
    '''
    def insertTokenAfter(self, newToken: 'HumdrumToken') -> None:
        if self._nextTokens == []:
            self._nextTokens.append(newToken)
            self.updateNextToken0()
        else:
            oldNextToken: HumdrumToken = self.nextTokens[0]
            self.nextTokens[0] = newToken
            self.updateNextToken0()

            newToken.previousTokens = [self]
            newToken.updatePreviousToken0()
            newToken.nextTokens = [oldNextToken]
            newToken.updateNextToken0()

            if oldNextToken.previousTokens == []:
                oldNextToken.previousTokens.append(newToken)
            else:
                oldNextToken.previousTokens[0] = newToken
            oldNextToken.updatePreviousToken0()

    '''
    //////////////////////////////
    //
    // HumdrumToken::getPreviousNonNullDataToken -- Returns the non-null
    //    data token which occurs before this token in the data in the same
    //    spine.  The default value is index 0, since mostly there will only
    //    be one previous token.
    '''
    def getPreviousNonNullDataToken(self, index: int = 0) -> t.Optional['HumdrumToken']:
        # handle Python-style negative indexing
        if index < 0:
            index += self.previousNonNullDataTokenCount

        if index < 0:
            return None

        if index >= self.previousNonNullDataTokenCount:
            return None

        return self._previousNonNullDataTokens[index]

    '''
    //////////////////////////////
    //
    // HumdrumToken::getNextNonNullDataTokenCount -- Returns the number of non-null
    //     data tokens which follow this token in the spine.
    '''
    @property
    def nextNonNullDataTokenCount(self) -> int:
        return len(self._nextNonNullDataTokens)

    @property
    def nextNonNullDataTokens(self) -> list['HumdrumToken']:
        return self._nextNonNullDataTokens

    '''
    //////////////////////////////
    //
    // HumdrumToken::getNextNonNullDataToken -- Returns the given next non-null token
    //    following this one in the spine.  The default value for index is 0 since
    //    the next non-null data token count will typically be 1.
    '''
    def getNextNonNullDataToken(self, index: int = 0) -> t.Optional['HumdrumToken']:
        # handle Python-style negative indexing
        if index < 0:
            index += self.nextNonNullDataTokenCount

        if index < 0:
            return None

        if index >= self.nextNonNullDataTokenCount:
            return None

        return self._nextNonNullDataTokens[index]

    '''
    //////////////////////////////
    //
    // HumdrumToken::getSlurDuration -- If the note has a slur start, then
    //    returns the duration until the endpoint; otherwise, returns 0;
    //    Expand later to handle slur ends and elided slurs.  The function
    //    HumdrumFileContent::analyzeSlurs() should be called before accessing
    //    this function.  If the slur duration was already calculated, return
    //    that value; otherwise, calculate from the location of a matching
    //    slur end.
    '''
    def getSlurDuration(self, scale: HumNumIn) -> HumNum:
        if not self.isKern:
            return opFrac(0)

        if self.isDefined('auto', 'slurDuration'):
            return self.getValueHumNum('auto', 'slurDuration')

        if self.isDefined('auto', 'slurEnd'):
            slurEndToken = self.getValueToken('auto', 'slurEnd')
            if slurEndToken is not None:
                return opFrac(
                    slurEndToken.getScaledDurationFromStart(scale)
                    - self.getScaledDurationFromStart(scale)
                )

        return opFrac(0)

    '''
    //////////////////////////////
    //
    // HumdrumToken::getDataType -- Get the exclusive interpretation type for
    //     the token.
    // @SEEALSO: isDataType
    '''
    @property
    def dataType(self) -> 'HumdrumToken':
        return self._address.dataType

    '''
    //////////////////////////////
    //
    // HumdrumToken::isDataType -- Returns true if the data type of the token
    //   matches the test data type.
    // @SEEALSO: getDataType getKern
    '''
    def isDataType(self, dtype: str) -> bool:
        if dtype.startswith('**'):
            return dtype == self.dataType.text

        return dtype == self.dataType.text[2:]

    '''
    //////////////////////////////
    //
    // HumdrumToken::isKern -- Returns true if the data type of the token
    //    is **kern.
    // @SEEALSO: isDataType
    '''
    @property
    def isKern(self) -> bool:
        # cache the result for performance
        try:
            return self._isKernCached
        except AttributeError:
            self._isKernCached: bool = self.isDataType('**kern')
            self._atLeastOneCachedDataTypePropertyExists = True
        return self._isKernCached

    '''
        isRecip -- Returns true if the data type of the token is **recip
    '''
    @property
    def isRecip(self) -> bool:
        # cache the result for performance
        try:
            return self._isRecipCached
        except AttributeError:
            self._isRecipCached: bool = self.isDataType('**recip')
            self._atLeastOneCachedDataTypePropertyExists = True
        return self._isRecipCached

    '''
    //////////////////////////////
    //
    // HumdrumToken::isMens -- Returns true if the data type of the token
    //    is **mens.
    // @SEEALSO: isDataType
    '''
    @property
    def isMens(self) -> bool:
        # cache the result for performance
        try:
            return self._isMensCached
        except AttributeError:
            self._isMensCached: bool = self.isDataType('**mens')
            self._atLeastOneCachedDataTypePropertyExists = True
        return self._isMensCached

    '''
    //////////////////////////////
    //
    // HumdrumToken::getSpineInfo -- Returns the spine split/merge history
    //    for the token.
    // @SEEALTO: setSpineInfo
    '''
    @property
    def spineInfo(self) -> str:
        return self._address.spineInfo

    @spineInfo.setter
    def spineInfo(self, newSpineInfo: str) -> None:
        self._address.spineInfo = newSpineInfo

    '''
    //////////////////////////////
    //
    // HumdrumToken::getLineIndex -- Returns the line index of the owning
    //    HumdrumLine for this token.
    // @SEEALTO: getLineNumber
    '''
    @property
    def lineIndex(self) -> int:
        return self._address.lineIndex

    '''
    //////////////////////////////
    //
    // HumdrumToken::getLineNumber -- Returns the line index plus 1.
    // @SEEALTO: getLineIndex
    '''
    @property
    def lineNumber(self) -> int:
        return self._address.lineNumber

    '''
    //////////////////////////////
    //
    // HumdrumToken::getFieldIndex -- Returns the index of the token the line.
    // @SEEALSO: getFieldIndex
    '''
    @property
    def fieldIndex(self) -> int:
        return self._address.fieldIndex

    @fieldIndex.setter
    def fieldIndex(self, newFieldIndex: int) -> None:
        self._address.fieldIndex = newFieldIndex

    '''
    //////////////////////////////
    //
    // HumdrumToken::getFieldNumber -- Returns the index +1 of the token the line
    // @SEEALSO: getFieldNumber
    '''
    @property
    def fieldNumber(self) -> int:
        return self._address.fieldNumber

    '''
    //////////////////////////////
    //
    // HumdrumToken::getTrack -- Get the track (similar to a staff in MEI).
    '''
    @property
    def track(self) -> int | None:
        # trackNum is a data member; cheaper than using the track property
        return self._address.trackNum

    @track.setter
    def track(self, track: int | None) -> None:
        # here we set track via address property for the checks
        self._address.track = track
        if self._atLeastOneCachedDataTypePropertyExists:
            # some cached properties depend on dataType,
            # which depends on ownerLine and track
            self._clearCachedDataTypeProperties()
    '''
    //////////////////////////////
    //
    // HumdrumToken::getSubtrack -- Get the subtrack (similar to a layer
    //    in MEI).
    '''
    @property
    def subTrack(self) -> int:
        return self._address.subTrack

    @subTrack.setter
    def subTrack(self, newSubTrack: int) -> None:
        self._address.subTrack = newSubTrack

    @property
    def subTrackCount(self) -> int:
        return self._address.subTrackCount

    @subTrackCount.setter
    def subTrackCount(self, newSubTrackCount: int) -> None:
        self._address.subTrackCount = newSubTrackCount

    @property
    def rscale(self) -> HumNum:
        return self._rscale

    @rscale.setter
    def rscale(self, newRscale: HumNumIn) -> None:
        self._rscale = opFrac(newRscale)

    '''
    //////////////////////////////
    //
    // HumdrumToken::getNextTokens -- Returns a list of the next
    //   tokens in the spine after this token.
    // @SEEALSO: getNextToken
    '''
    @property
    def nextTokens(self) -> list['HumdrumToken']:
        return self._nextTokens

    @nextTokens.setter
    def nextTokens(self, newNextTokens: list['HumdrumToken']) -> None:
        self._nextTokens = newNextTokens
        self.updateNextToken0()

    '''
    //////////////////////////////
    //
    // HumdrumToken::getNextToken -- Returns the next token in the
    //    spine.  Since the next token count is usually one, the default
    //    index value is zero.  When there is no next token (when the current
    //    token is a spine terminaor), then NULL will be returned.
    // default value: index = 0
    // @SEEALSO: getNextTokens, getPreviousToken
    '''
    def nextToken(self, index: int) -> t.Optional['HumdrumToken']:
        if 0 <= index < len(self._nextTokens):
            return self._nextTokens[index]
        return None

    @property
    def nextToken0(self) -> t.Optional['HumdrumToken']:
        return self._nextToken0

    def updateNextToken0(self) -> None:
        self._nextToken0 = self.nextToken(0)

    '''
    //////////////////////////////
    //
    // HumdrumToken::addNextNonNullToken --
    '''
    def addNextNonNullDataToken(self, token: 'HumdrumToken') -> None:
        if token in self._nextNonNullDataTokens:
            return

        self._nextNonNullDataTokens.append(token)

    '''
    //////////////////////////////
    //
    // HumdrumToken::getPreviousToken -- Returns the previous token in the
    //    spine.  Since the previous token count is usually one, the default
    //    index value is zero.
    // default value: index = 0
    '''
    def previousToken(self, index: int) -> t.Optional['HumdrumToken']:
        if 0 <= index < len(self._previousTokens):
            return self._previousTokens[index]
        return None

    @property
    def previousToken0(self) -> t.Optional['HumdrumToken']:
        return self._previousToken0

    @property
    def previousTokenN(self) -> t.Optional['HumdrumToken']:
        if self._previousTokens:
            return self._previousTokens[-1]
        return None

    def updatePreviousToken0(self) -> None:
        self._previousToken0 = self.previousToken(0)

    '''
    //////////////////////////////
    //
    // HumdrumToken::getPreviousTokens -- Returns a list of the previous
    //    tokens in the spine before this token.
    '''
    @property
    def previousTokens(self) -> list['HumdrumToken']:
        return self._previousTokens

    @previousTokens.setter
    def previousTokens(self, newPreviousTokens: list['HumdrumToken']) -> None:
        self._previousTokens = newPreviousTokens
        self.updatePreviousToken0()

    '''
    //////////////////////////////
    //
    // HumdrumToken::getNextFieldToken --
        This returns the token representing the next field in the ownerLine of this token --gregc
    '''
    @property
    def nextFieldToken(self) -> t.Optional['HumdrumToken']:
        from converter21.humdrum import HumdrumLine
        self.ownerLine: HumdrumLine
        if self.ownerLine is None:
            return None
        if self.fieldIndex >= self.ownerLine.tokenCount - 1:
            return None
        return self.ownerLine[self.fieldIndex + 1]

    '''
    //////////////////////////////
    //
    // HumdrumToken::getPreviousFieldToken --
        This returns the token representing the previous field in the ownerLine of this token
    '''
    @property
    def previousFieldToken(self) -> t.Optional['HumdrumToken']:
        if self.ownerLine is None:
            return None
        if self.fieldIndex < 1:
            return None
        return self.ownerLine[self.fieldIndex - 1]

    '''
    //////////////////////////////
    //
    // HumdrumToken::analyzeDuration -- Currently reads the duration of
    //   **kern and **recip data.  Add more data types here such as **koto.
    '''
    def analyzeDuration(self) -> None:
        self._rhythmAnalyzed = True

        if self.text == NULL_DATA:
            self._duration = opFrac(-1)
            return

        if self.isComment:
            self._duration = opFrac(-1)
            return

        if self.isInterpretation:
            self._duration = opFrac(-1)
            return

        if self.isBarline:
            self._duration = opFrac(-1)
            return

        if self.hasRhythm:
            if self.isNonNullData:
                if self.isKern:
                    self._duration = Convert.recipToDuration(self.text)
                    if self.isGrace:
                        recipWithoutQsOrDots: str = self.text.replace('q', '').replace('.', '')
                        self._graceVisualDuration = Convert.recipToDuration(recipWithoutQsOrDots)
                elif self.isRecip:
                    self._duration = Convert.recipToDuration(self.text)
                elif self.isMens:
                    self._duration = Convert.mensToDuration(self.text)
                else:
                    # LATER: **koto not supported...
                    self._duration = opFrac(-1)
            else:
                self._duration = opFrac(-1)
        else:
            self._duration = opFrac(-1)

    '''
    ///////////////////////////////
    //
    // HumdrumToken::isManipulator -- Returns true if token is one of:
    //    SPLIT_TOKEN     = "*^"  == spine splitter
    //    MERGE_TOKEN     = "*v"  == spine merger
    //    EXCHANGE_TOKEN  = "*x"  == spine exchanger
    //    ADD_TOKEN       = "*+"  == spine adder
    //    TERMINATE_TOKEN = "*-"  == spine terminator
    //    **...  == exclusive interpretation
    '''
    @property
    def isManipulator(self) -> bool:
        # cache the result for performance
        try:
            return self._isManipulatorCached
        except AttributeError:
            self._isManipulatorCached: bool = (
                self.isSplitInterpretation
                or self.isMergeInterpretation
                or self.isExchangeInterpretation
                or self.isAddInterpretation
                or self.isTerminateInterpretation
                or self.isExclusiveInterpretation
            )
            self._atLeastOneCachedTokenTextPropertyExists = True
        return self._isManipulatorCached

    '''
        isRecipOnly returns True if the token is just a recip 'value', like you would normally
        see in a **recip spine.  This is in support of such tokens in **kern, where they are
        (apparently) intended to be invisible rests.  See rds-scores/R415_Web-s28p2-3m11-19.krn,
        measure 19, where the right hand Piano staff has a triplet-eighth set of 3 such tokens
        ('12').  Without making these into invisible rests, music21 ends up thinking it's a
        short measure.
    '''
    @property
    def isRecipOnly(self) -> bool:
        # cache the result for performance
        try:
            return self._isRecipOnlyCached
        except AttributeError:
            self._isRecipOnlyCached: bool = (
                re.match(r'^[\d]+(%[\d]+)?[.]*$', self.text) is not None
            )
            self._atLeastOneCachedTokenTextPropertyExists = True
        return self._isRecipOnlyCached

    '''
    //////////////////////////////
    //
    // HumdrumToken::getDuration -- Returns the duration of the token.  The token
    //    does not necessarily need to have any explicit duration, as the returned
    //    value will also include implicit duration calculated in analyzeRhythm
    //    in the HumdrumFileStructure class.
    '''
    def scaledDuration(self, scale: HumNumIn) -> HumNum:
        # self.duration will trigger rhythm analysis if necessary
        return opFrac(self.duration * opFrac(scale))

    @property
    def duration(self) -> HumNum:
        if not self._rhythmAnalyzed:
            self.analyzeDuration()

        return self._duration

    @duration.setter
    def duration(self, newDuration: HumNumIn) -> None:
        self._rhythmAnalyzed = True
        self._duration = opFrac(newDuration)

    @property
    def graceVisualDuration(self) -> HumNum:
        if not self._rhythmAnalyzed:
            self.analyzeDuration()

        return self._graceVisualDuration

    '''
    //////////////////////////////
    //
    // HumdrumToken::getDots -- Count the number of '.' characters in token string.
    //    Terminating the count at the first occurrence of the separator character,
    //    which is by default a space character.
    '''
    @property
    def dotCount(self) -> int:
        count: int = 0

        for ch in self.text:
            if ch == '.':
                count += 1
            if ch == ' ':
                break

        return count

    '''
    //////////////////////////////
    //
    // HumdrumToken::getDurationNoDots -- Return the duration of the
    //   note excluding any dots.
    '''
    @property
    def durationNoDots(self) -> HumNum:
        dotCount: int = self.dotCount
        if dotCount == 0:
            return self.duration

        bot: int = int(pow(2.0, dotCount + 1)) - 1
        top: int = int(pow(2.0, dotCount))
        dotMultiplier: HumNum = opFrac(Fraction(top, bot))
        return opFrac(self.duration * dotMultiplier)

    def scaledDurationNoDots(self, scale: HumNumIn) -> HumNum:
        return opFrac(self.durationNoDots * opFrac(scale))

    '''
    //////////////////////////////
    //
    // HumdrumToken::getDurationFromStart -- Returns the duration from the
    //   start of the owning HumdrumFile to the starting time of the
    //   owning HumdrumLine for the token.  The durationFromStart is
    //   in reference to the start of the token, not the end of the token,
    //   which may be on another HumdrumLine.
    '''
    def getScaledDurationFromStart(self, scale: HumNumIn) -> HumNum:
        lineDur = self.ownerLine.durationFromStart
        return opFrac(lineDur * opFrac(scale))

    ''' durationFromStart property for unscaled getDurationFromStart --gregc '''
    @property
    def durationFromStart(self) -> HumNum:
        return self.ownerLine.durationFromStart

    '''
    //////////////////////////////
    //
    // HumdrumToken::getDurationToEnd -- Returns the duration from the
    //   start of the current line to the start of the last line
    //   (the duration of the last line is always zero, so the duration
    //   to end is always the duration to the end of the last non-zero
    //   duration line.
    '''
    def getScaledDurationToEnd(self, scale: HumNumIn) -> HumNum:
        if self.ownerLine is None:  # bugfix, but current clients probably don't care --gregc
            return opFrac(0)
        return opFrac(self.ownerLine.durationToEnd * opFrac(scale))

    ''' durationToEnd property for unscaled getDurationToEnd --gregc '''
    @property
    def durationToEnd(self) -> HumNum:
        if self.ownerLine is None:  # bugfix, but current clients probably don't care --gregc
            return opFrac(0)
        return self.ownerLine.durationToEnd

    '''
    //////////////////////////////
    //
    // HumdrumToken::getBarlineDuration -- Returns the duration between
    //   the next and previous barline.  If the token is a barline token,
    //   then return the duration to the next barline.  The barline duration data
    //   is filled in automatically when reading a file with the
    //   HumdrumFileStructure::analyzeMeter() function.  The duration
    //   will always be non-positive if the file is read with HumdrumFileBase and
    //   analyzeMeter() is not run to analyze the data.
    '''
    def getScaledBarlineDuration(self, scale: HumNumIn) -> HumNum:
        if self.ownerLine is None:
            return opFrac(0)
        return opFrac(self.ownerLine.barlineDuration * opFrac(scale))

    @property
    def barlineDuration(self) -> HumNum:
        if self.ownerLine is None:
            return opFrac(0)
        return self.ownerLine.barlineDuration

    '''
    //////////////////////////////
    //
    // HumdrumToken::getDurationToBarline -- Get duration from start of token to
    //      the start of the next barline. Units are quarter notes, unless scale
    //      is set to a value other than 1.
    '''
    def getScaledDurationToBarline(self, scale: HumNumIn) -> HumNum:
        if self.ownerLine is None:
            return opFrac(0)
        return opFrac(self.ownerLine.durationToBarline * opFrac(scale))

    @property
    def durationToBarline(self) -> HumNum:
        if self.ownerLine is None:
            return opFrac(0)
        return self.ownerLine.durationToBarline

    '''
    //////////////////////////////
    //
    // HumdrumToken::getDurationFromBarline -- Get duration from start of token to
    //      the previous barline. Units are quarter notes, unless scale
    //      is set to a value other than 1.
    '''
    def getScaledDurationFromBarline(self, scale: HumNumIn) -> HumNum:
        if self.ownerLine is None:
            return opFrac(0)
        return opFrac(self.ownerLine.durationFromBarline * opFrac(scale))

    @property
    def durationFromBarline(self) -> HumNum:
        if self.ownerLine is None:
            return opFrac(0)
        return self.ownerLine.durationFromBarline

    '''
    //////////////////////////////
    //
    // HumdrumToken::hasRhythm -- Returns true if the exclusive interpretation
    //    contains rhythmic data which will be used for analyzing the
    //    duration of a HumdrumFile, for example.
        Weird that this supports **recip, but analyzeDuration does not. --gregc
        Both need to add *koto support. --gregc
    '''
    @property
    def hasRhythm(self) -> bool:
        # cache the result for performance
        try:
            return self._hasRhythmCached
        except AttributeError:
            self._hasRhythmCached: bool = self.dataType.text in ('**kern', '**recip', '**mens')
            self._atLeastOneCachedDataTypePropertyExists = True
        return self._hasRhythmCached

    '''
    //////////////////////////////
    //
    // HumdrumToken::hasBeam -- True if **kern has L, J, K, or k.
    '''
    @property
    def hasBeam(self) -> bool:
        # cache the result for performance
        try:
            return self._hasBeamCached
        except AttributeError:
            self._hasBeamCached: bool = (
                'L' in self.text
                or 'J' in self.text
                or 'K' in self.text
                or 'k' in self.text
            )
            self._atLeastOneCachedTokenTextPropertyExists = True
        return self._hasBeamCached

    '''
    //////////////////////////////
    //
    // HumdrumToken::hasFermata --
    '''
    @property
    def hasFermata(self) -> bool:
        # cache the result for performance
        try:
            return self._hasFermataCached
        except AttributeError:
            self._hasFermataCached: bool = ';' in self.text
            self._atLeastOneCachedTokenTextPropertyExists = True
        return self._hasFermataCached

    '''
    //////////////////////////////
    //
    // HumdrumToken::isStaff -- Returns true if the spine type represents
    //   a notated staff.
    '''
    @property
    def isStaffDataType(self) -> bool:
        # cache the result for performance
        try:
            return self._isStaffDataTypeCached
        except AttributeError:
            self._isStaffDataTypeCached: bool = self.isKern or self.isMens
            self._atLeastOneCachedDataTypePropertyExists = True
        return self._isStaffDataTypeCached

    @property
    def isStaffInterpretation(self) -> bool:
        # cache the result for performance
        try:
            return self._isStaffInterpretationCached
        except AttributeError:
            self._isStaffInterpretationCached: bool = self.text.startswith('*staff')
            self._atLeastOneCachedTokenTextPropertyExists = True
        return self._isStaffInterpretationCached

    @property
    def staff(self) -> str:
        # can be '1', '2', etc, or even '1/2' e.g. if this **dynam spine applies to two staves
        if not self.isStaffInterpretation:
            return ''
        return self.text[6:]

    @property
    def staffNums(self) -> list[int]:
        staffStr: str = self.staff
        staffNumList: list[int] = []
        for m in re.finditer(r'(\d+)/?', staffStr):
            if m.group(1) is None:
                break
            staffNumList.append(int(m.group(1)))
        return staffNumList

    @property
    def isPart(self) -> bool:
        # cache the result for performance
        try:
            return self._isPartCached
        except AttributeError:
            self._isPartCached: bool = self.text.startswith('*part')
            self._atLeastOneCachedTokenTextPropertyExists = True
        return self._isPartCached

    @property
    def partNum(self) -> int:
        if not self.isPart:
            return -1

        # ignore characters after the number
        m = re.match(r'^\*part(\d+).*$', self.text)
        if m is None:
            return -1

        return int(m.group(1))

    @property
    def isGroup(self) -> bool:
        # cache the result for performance
        try:
            return self._isGroupCached
        except AttributeError:
            self._isGroupCached: bool = self.text.startswith('*group')
            self._atLeastOneCachedTokenTextPropertyExists = True
        return self._isGroupCached

    @property
    def groupNum(self) -> int:
        if not self.isGroup:
            return -1

        # ignore characters after the number
        m = re.match(r'^\*group(\d+).*$', self.text)
        if m is None:
            return -1

        return int(m.group(1))

    @property
    def isStria(self) -> bool:
        return self.text.startswith('*stria')

    @property
    def stria(self) -> int:
        if not self.isStria:
            return DEFAULT_LINES_PER_STAFF
        m = re.match(r'^\*stria(\d+)', self.text)
        if m is None:
            return DEFAULT_LINES_PER_STAFF
        numLines: int = int(m.group(1))
        if numLines > MAX_LINES_PER_STAFF:
            return DEFAULT_LINES_PER_STAFF
        return numLines

    @property
    def isScale(self) -> bool:
        return self.text.startswith('*scale:')

    @property
    def isSize(self) -> bool:
        return self.text.startswith('*size:')

    @property
    def scale(self) -> float:
        if not self.isScale and not self.isSize:
            return 1.0
        m = re.search(r':(\d+(\.?\d*)?)%', self.text)
        if m:
            return float(m.group(1))
        return 1.0

    '''
    //////////////////////////////
    //
    // HumdrumToken::isRest -- Returns true if the token is a rest.
        Q: Only a few of these (e.g. isRest) bother to resolve null tokens.  Why? --gregc
    '''
    @property
    def isRest(self) -> bool:
        # assumption: isRest will only be called after self._nullResolution has been
        # set once and for all
        try:
            return self._isRestCached
        except AttributeError:
            tokenText: str = self.text
            if self.isNull:
                tokenText = self.nullResolution.text

            self._isRestCached: bool = False

            # BUGFIX: Without this "if self.isData", isRest('**kern') will return True
            # BUGFIX: (there's an 'r')
            if self.isData:
                if self.isKern:
                    self._isRestCached = Convert.isKernRest(tokenText)
                elif self.isMens:
                    self._isRestCached = Convert.isMensRest(tokenText)

            # isRest changes if tokenText changes, and also if dataType changes
            self._atLeastOneCachedTokenTextPropertyExists = True
            self._atLeastOneCachedDataTypePropertyExists = True

        return self._isRestCached

    '''
    //////////////////////////////
    //
    // HumdrumToken::isNote -- Returns true if the token is a (kern) note
    //     (possessing a pitch).
        Q: isNote is an example that doesn't bother to resolve null tokens. Why? --gregc
    '''
    @property
    def isNote(self) -> bool:
        try:
            return self._isNoteCached
        except AttributeError:
            self._isNoteCached: bool = False
            if not self.isData or self.isNull:
                pass
            elif self.isKern:
                self._isNoteCached = Convert.isKernNote(self.text)
            elif self.isMens:
                self._isNoteCached = Convert.isMensNote(self.text)

        return self._isNoteCached

    '''
    //////////////////////////////
    //
    // HumdrumToken::isPitched -- True if not a rest or an unpitched note.
    '''
    @property
    def isPitched(self) -> bool:
        if self.isKern:
            return 'r' not in self.text and 'R' not in self.text

        # Don't know data type so return false for now:
        return False

    '''
    //////////////////////////////
    //
    // HumdrumToken::isUnpitched -- True if has an unpitched marker
        This is checking for an unpitched note (e.g. in a percussion part), not a rest.
    '''
    @property
    def isUnpitched(self) -> bool:
        if self.isKern:
            return 'R' in self.text

        # Don't know data type so return false for now:
        return False

    '''
    //////////////////////////////
    //
    // HumdrumToken::isSustainedNote -- Returns true if the token represents
    //     a sounding note, but not the attack portion.  Should only be
    //     applied to **kern data.
    '''
    @property
    def isSustainedNote(self) -> bool:
        token: HumdrumToken = self
        if self.isNull:
            token = self.nullResolution
        return token.isSecondaryTiedNote

    '''
    //////////////////////////////
    //
    // HumdrumToken::isNoteAttack -- Returns true if the token represents
    //     the attack of a note.  Should only be applied to **kern data, but
    //     this function does not check for that for efficiency reasons.
    '''
    @property
    def isNoteAttack(self) -> bool:
        token: HumdrumToken = self
        if self.isNull:
            token = self.nullResolution

        if token.isRest:
            return False

        return not token.isSecondaryTiedNote

    '''
    //////////////////////////////
    //
    // HumdrumToken::isInvisible -- True if a barline and is invisible (contains
    //     a "-" styling), or a note/rest contains the string "yy" which is
    //     interpreted as meaning make it invisible.
    '''
    @property
    def isInvisible(self) -> bool:
        if not self.isKern:
            return False

        if self.isBarline:
            return '-' in self.text
        if self.isData:
            return 'yy' in self.text

        return False

    '''
    //////////////////////////////
    //
    // HumdrumToken::isGrace -- True if a **kern note has no duration.
        Should isGrace handle grace notes with duration differently? --gregc
    '''
    @property
    def isGrace(self) -> bool:
        if not self.isKern:
            return False
        if not self.isData:
            return False
        # return True if 'q' or 'qq' is in self.text
        return 'q' in self.text

    '''
    //////////////////////////////
    //
    // HumdrumToken::isClef -- True if a clef.
    '''
    @property
    def isClef(self) -> bool:
        if not (self.isKern or self.isMens):
            return False
        if not self.isInterpretation:
            return False

        if self.text.startswith('*clef'):
            return True

        # check for 'auto', 'clef' (added by HumdrumFile.py)
        autoClef: str | None = self.getValueString('auto', 'clef')
        if autoClef and autoClef.startswith('*clef'):
            return True

        return False


    @property
    def clef(self) -> str:
        if not (self.isKern or self.isMens):
            return ''
        if not self.isInterpretation:
            return ''

        if self.text.startswith('*clef'):
            return self.text[5:]

        # check for 'auto', 'clef' (added by HumdrumFile.py)
        autoClef: str | None = self.getValueString('auto', 'clef')
        if autoClef:
            return autoClef[5:]

        return ''

    '''
    //////////////////////////////
    //
    // HumdrumToken::isModernClef -- True if a modern clef.
    '''
    @property
    def isModernClef(self) -> bool:
        if not (self.isKern or self.isMens):
            return False
        if not self.isInterpretation:
            return False

        return self.text.startswith('*mclef')

    @property
    def modernClef(self) -> str:
        if not self.isModernClef:
            return ''
        return self.text[6:]

    '''
    //////////////////////////////
    //
    // HumdrumToken::isOriginalClef -- True if an original clef.
    '''
    @property
    def isOriginalClef(self) -> bool:
        if not (self.isKern or self.isMens):
            return False
        if not self.isInterpretation:
            return False

        return self.text.startswith('*oclef')

    @property
    def originalClef(self) -> str:
        if not self.isOriginalClef:
            return ''
        return self.text[6:]

    '''
    //////////////////////////////
    //
    // HumdrumToken::isKeySignature -- True if a key signature.
    '''
    @property
    def isKeySignature(self) -> bool:
        return self.text.startswith('*k[') and self.text.endswith(']')

    @property
    def keySignature(self) -> str:
        if not self.isKeySignature:
            return ''
        return self.text[3:-1]

    '''
    //////////////////////////////
    //
    // HumdrumToken::isOriginalKeySignature -- True if an original key signature.
    '''
    @property
    def isOriginalKeySignature(self) -> bool:
        return self.text.startswith('*ok[') and self.text.endswith(']')

    @property
    def originalKeySignature(self) -> str:
        if not self.isOriginalKeySignature:
            return ''
        return self.text[4:-1]

    '''
    //////////////////////////////
    //
    // HumdrumToken::isModernKeySignature -- True if a modern key signature.
    '''
    @property
    def isModernKeySignature(self) -> bool:
        return self.text.startswith('*mk[') and self.text.endswith(']')

    @property
    def modernKeySignature(self) -> str:
        if not self.isModernKeySignature:
            return ''
        return self.text[4:-1]

    '''
    //////////////////////////////
    //
    // HumdrumToken::isKeyDesignation -- True if a **kern key designation.
    //   *C:
    //   *A-:
    //   *c#:
    //   *d:dor
    '''
    @property
    def isKeyDesignation(self) -> bool:
        # we have to do an re.match because *grp:3 passed our simple check
        return re.match(KEY_DESIGNATION_PATTERN, self.text) is not None

    @property
    def keyDesignation(self) -> tuple[str | None, str | None]:
        # *d:dor returns ('d', 'dor'), *A-: returns ('A-', None)
        m = re.match(KEY_DESIGNATION_PATTERN, self.text)
        if m:
            return (m.group(1), m.group(2))
        return (None, None)

    '''
    //////////////////////////////
    //
    // HumdrumToken::isTimeSignature -- True if a **kern time signature.
    '''
    @property
    def isTimeSignature(self) -> bool:
        if len(self.text) < 3:
            return False
        if not self.text.startswith('*M'):
            return False
        if not self.text[2].isdigit():
            return False
        if '/' not in self.text:
            return False
        return True

    @property
    def timeSignature(self) -> tuple[int | None, int | None]:
        # returns (top: int, bot: int) or (None, None)
        if not self.isTimeSignature:
            return (None, None)

        # deal with triplet-whole note beats later: r'*M(\d+)/(\d+)%(\d+)'
        m = re.match(r'^\*M([\d]+)/([\d]+)', self.text)
        if m:
            top: int = int(m.group(1))
            bot: int = int(m.group(2))
            botStr: str = m.group(2)
            if botStr == '0':       # breve (2 whole notes)
                top *= 2
                bot = 1
            elif botStr == '00':    # longa (4 whole notes)
                top *= 4
                bot = 1
            elif botStr == '000':   # maxima (8 whole notes)
                top *= 8
                bot = 1
            elif botStr == '0000':  # double maxima (16 whole notes)
                top *= 16
                bot = 1
            return (top, bot)

        return (None, None)

    @property
    def timeSignatureRatioString(self) -> str:  # returns '{top}/{bot}'
        top, bot = self.timeSignature
        if top is None or bot is None:
            return ''

        return str(top) + '/' + str(bot)

    '''
    //////////////////////////////
    //
    // HumdrumToken::isTempo -- True if a **kern tempo.

        e.g. *MM124 or **MM124.67
        e.g. *MM[Adagio]
    '''
    @property
    def isTempo(self) -> bool:
        if len(self.text) < 4:
            return False
        if not self.text.startswith('*MM'):
            return False
        if not self.text[3].isdigit() and not self.text[3] == '[':
            return False
        return True

    @property
    def tempoBPM(self) -> int:
        if not self.isTempo:
            return 0
        m = re.match(r'^\*MM(\d+\.?\d*)', self.text)
        if m:
            tempo: float = float(m.group(1))
            return int(tempo + 0.5)
        return 0

    @property
    def tempoName(self) -> str:
        if not self.isTempo:
            return ''
        m = re.match(r'^\*MM[(.+)]', self.text)
        if m:
            return m.group(1)
        return ''

    '''
    //////////////////////////////
    //
    // HumdrumToken::isMensurationSymbol -- True if a **kern mensuration Symbol.
    '''
    @property
    def isMensurationSymbol(self) -> bool:
        return self.text.startswith('*met(') and self.text.endswith(')')

    '''
    //////////////////////////////
    //
    // HumdrumToken::isOriginalMensurationSymbol -- True if a **kern original mensuration Symbol.
    '''
    @property
    def isOriginalMensurationSymbol(self) -> bool:
        return self.text.startswith('*omet(') and self.text.endswith(')')

    @property
    def mensurationSymbol(self) -> str:
        m = None
        if self.isMensurationSymbol:
            m = re.match(r'^\*met\((.+)\)', self.text)
        elif self.isOriginalMensurationSymbol:
            m = re.match(r'^\*omet\((.+)\)', self.text)

        if m:
            return m.group(1)
        return ''


    '''
    //////////////////////////////
    //
    // HumdrumToken::isInstrumentDesignation -- Such as *Iclars for B-flat clarinet.
    '''
    @property
    def isInstrumentCode(self) -> bool:
        if len(self.text) < 3:
            # has to be '*I' and at least one lower-case letter
            return False
        if not self.text.startswith('*I'):
            return False
        if not self.text[2:].isalpha():
            # return False for '*I""blah', "*I'Blah", etc
            return False
        if not self.text[2:].islower():
            return False
        return True

    '''
        isInstrumentClassCode -- such as *ICbras for BrassInstrument
    '''
    @property
    def isInstrumentClassCode(self) -> bool:
        if len(self.text) < 4:
            # has to be '*IC' and at least one lower-case letter
            return False
        if not self.text.startswith('*IC'):
            return False
        if not self.text[3:].isalpha():
            return False
        if not self.text[3:].islower():
            return False
        return True

    '''
    //////////////////////////////
    //
    // HumdrumToken::isInstrumentName -- True if an instrument name token.
    '''
    @property
    def isInstrumentName(self) -> bool:
        if len(self.text) < 4:
            # has to be *I" plus at least one character
            return False
        if self.text[3] == '"':
            # avoid *I""Blah, which is InstrumentGroupName
            return False
        return self.text.startswith('*I"')

    '''
        isInstrumentGroupName
    '''
    @property
    def isInstrumentGroupName(self) -> bool:
        if len(self.text) < 5:
            # has to be *I"" plus at least one character
            return False
        return self.text.startswith('*I""')

    '''
    //////////////////////////////
    //
    // HumdrumToken::isInstrumentAbbreviation -- True if an instrument abbreviation token.
    '''
    @property
    def isInstrumentAbbreviation(self) -> bool:
        if len(self.text) < 4:
            # has to be *I' plus at least one character
            return False
        if self.text[3] == "'":
            # avoid *I''Blah, which is InstrumentGroupAbbreviation
            return False
        return self.text.startswith("*I'")

    '''
        isInstrumentGroupAbbreviation
    '''
    @property
    def isInstrumentGroupAbbreviation(self) -> bool:
        if len(self.text) < 5:
            # has to be *I'' plus at least one character
            return False
        return self.text.startswith("*I''")

    '''
        instrumentCode -- return 'clars' from '*Iclars', for example
    '''
    @property
    def instrumentCode(self) -> str:
        if not self.isInstrumentCode:
            return ''
        # everything after *I
        return self.text[2:]

    '''
        instrumentClassCode -- return 'bras' from '*ICbras', for example
    '''
    @property
    def instrumentClassCode(self) -> str:
        if not self.isInstrumentClassCode:
            return ''
        # everything after *IC
        return self.text[3:]

    '''
    //////////////////////////////
    //
    // HumdrumToken::getInstrumentName --
    '''
    @property
    def instrumentName(self) -> str:
        if not self.isInstrumentName:
            return ''
        # everything after *I"
        return self.text[3:]

    '''
        instrumentGroupName
    '''
    @property
    def instrumentGroupName(self) -> str:
        if not self.isInstrumentGroupName:
            return ''
        # everything after *I""
        return self.text[4:]

    '''
    //////////////////////////////
    //
    // HumdrumToken::getInstrumentAbbreviation --
    '''
    @property
    def instrumentAbbreviation(self) -> str:
        if not self.isInstrumentAbbreviation:
            return ''
        # everything after *I'
        return self.text[3:]

    '''
        instrumentGroupAbbreviation
    '''
    @property
    def instrumentGroupAbbreviation(self) -> str:
        if not self.isInstrumentGroupAbbreviation:
            return ''
        # everything after *I''
        return self.text[4:]

    '''
        isInstrumentTranspose
    '''
    @property
    def isInstrumentTranspose(self) -> bool:
        if self.text.startswith('*ITrd'):
            return True
        return False

    '''
        instrumentTranspose
    '''
    @property
    def instrumentTranspose(self) -> str:
        if not self.isInstrumentTranspose:
            return ''
        # everything after '*ITr' -> 'dNcM'
        return self.text[4:]

    '''
        isTranspose
    '''
    @property
    def isTranspose(self) -> bool:
        if self.text.startswith('*Trd'):
            return True
        return False

    '''
        instrumentTranspose
    '''
    @property
    def transpose(self) -> str:
        if not self.isTranspose:
            return ''
        # everything after '*Tr' -> 'dNcM'
        return self.text[3:]

    '''
    //////////////////////////////
    //
    // HumdrumToken::hasSlurStart -- Returns true if the **kern token has
    //     a '(' character.
    '''
    @property
    def hasSlurStart(self) -> bool:
        if not self.isKern:
            return False
        return Convert.hasKernSlurStart(self.text)

    '''
    //////////////////////////////
    //
    // HumdrumToken::hasSlurEnd -- Returns true if the **kern token has
    //     a ')' character.
    '''
    @property
    def hasSlurEnd(self) -> bool:
        if not self.isKern:
            return False
        return Convert.hasKernSlurEnd(self.text)

    '''
    //////////////////////////////
    //
    // HumdrumToken::hasVisibleAccidental -- Returns true if the accidental
    //    of a **kern note is viewable if rendered to graphical notation.
    //     return values:
    //      false
    //      true
    //      None = undefined;
    '''
    def hasVisibleAccidental(self, subtokenIndex: int) -> bool | None:
        humLine = self.ownerLine
        if humLine is None:
            return None
        humFile = humLine.ownerFile
        if humFile is None:
            return None

        # make sure we've done the full accidental analysis on the whole file
        if not humFile.getValueBool('auto', 'accidentalAnalysis'):
            successful = humFile.analyzeKernAccidentals()
            if not successful:
                return None

        return self.getValueBool('auto', str(subtokenIndex), 'visualAccidental')

    '''
    //////////////////////////////
    //
    // HumdrumToken::hasCautionaryAccidental -- Returns true if the accidental
    //    of a **kern note is viewable if rendered to graphical notation.
    //     return values:
    //      false
    //      true
    //      None = undefined;
    '''
    def hasCautionaryAccidental(self, subtokenIndex: int) -> bool | None:
        humLine = self.ownerLine
        if humLine is None:
            return None
        humFile = humLine.ownerFile
        if humFile is None:
            return None

        # make sure we've done the full accidental analysis on the whole file
        if not humFile.getValueBool('auto', 'accidentalAnalysis'):
            successful = humFile.analyzeKernAccidentals()
            if not successful:
                return None

        return self.getValueBool('auto', str(subtokenIndex), 'cautionaryAccidental')

    def cautionaryAccidental(self, subtokenIndex: int) -> str | None:
        humLine = self.ownerLine
        if humLine is None:
            return None
        humFile = humLine.ownerFile
        if humFile is None:
            return None

        # make sure we've done the full accidental analysis on the whole file
        if not humFile.getValueBool('auto', 'accidentalAnalysis'):
            successful = humFile.analyzeKernAccidentals()
            if not successful:
                return None

        return self.getValueString('auto', str(subtokenIndex), 'cautionaryAccidental')

    def hasEditorialAccidental(self, subtokenIndex: int) -> bool | None:
        humLine = self.ownerLine
        if humLine is None:
            return None
        humFile = humLine.ownerFile
        if humFile is None:
            return None

        # make sure we've done the full accidental analysis on the whole file
        if not humFile.getValueBool('auto', 'accidentalAnalysis'):
            successful = humFile.analyzeKernAccidentals()
            if not successful:
                return None

        return self.getValueBool('auto', str(subtokenIndex), 'editorialAccidental')

    def editorialAccidentalStyle(self, subtokenIndex: int) -> str | None:
        humLine = self.ownerLine
        if humLine is None:
            return None
        humFile = humLine.ownerFile
        if humFile is None:
            return None

        # make sure we've done the full accidental analysis on the whole file
        if not humFile.getValueBool('auto', 'accidentalAnalysis'):
            successful = humFile.analyzeKernAccidentals()
            if not successful:
                return None

        editStyle: str | None = self.getValueString(
            'auto', str(subtokenIndex), 'editorialAccidentalStyle'
        )
        if editStyle is None:
            return ''
        return editStyle

    '''
    //////////////////////////////
    //
    // HumdrumToken::hasLigatureBegin --
    '''
    @property
    def hasLigatureBegin(self) -> bool:
        if not self.isMens:
            return False
        return Convert.hasLigatureBegin(self.text)

    '''
    //////////////////////////////
    //
    // HumdrumToken::hasRectaLigatureBegin --
    '''
    @property
    def hasRectaLigatureBegin(self) -> bool:
        if not self.isMens:
            return False
        return Convert.hasRectaLigatureBegin(self.text)

    '''
    //////////////////////////////
    //
    // HumdrumToken::hasObliquaLigatureBegin --
    '''
    @property
    def hasObliquaLigatureBegin(self) -> bool:
        if not self.isMens:
            return False
        return Convert.hasObliquaLigatureBegin(self.text)

    '''
    //////////////////////////////
    //
    // HumdrumToken::hasStemDirection --
    '''
    @property
    def getStemDirection(self) -> str | None:
        if not self.isKern:
            return None
        return Convert.getKernStemDirection(self.text)

    '''
    //////////////////////////////
    //
    // HumdrumToken::allSameBarlineStyle --
    '''
    @property
    def allSameBarlineStyle(self) -> bool:
        if self.ownerLine is None:
            return True
        return self.ownerLine.allSameBarlineStyle

    '''
    //////////////////////////////
    //
    // HumdrumToken::hasLigatureEnd --
    '''
    @property
    def hasLigatureEnd(self) -> bool:
        if not self.isMens:
            return False
        return Convert.hasLigatureEnd(self.text)

    '''
    //////////////////////////////
    //
    // HumdrumToken::hasRectaLigatureEnd --
    '''
    @property
    def hasRectaLigatureEnd(self) -> bool:
        if not self.isMens:
            return False
        return Convert.hasRectaLigatureEnd(self.text)

    '''
    //////////////////////////////
    //
    // HumdrumToken::hasObliquaLigatureEnd --
    '''
    @property
    def hasObliquaLigatureEnd(self) -> bool:
        if not self.isMens:
            return False
        return Convert.hasObliquaLigatureEnd(self.text)

    '''
    //////////////////////////////
    //
    // HumdrumToken::isSecondaryTiedNote -- Returns true if the token
    //     is a (kern) note (possessing a pitch) and has '_' or ']' characters.
    '''
    @property
    def isSecondaryTiedNote(self) -> bool:
        if not self.isKern:
            return False

        return Convert.isKernSecondaryTiedNote(self.text)

    '''
    //////////////////////////////
    //
    // HumdrumToken::isBarline -- Returns true if the first character is an
    //   equals sign.
    '''
    @property
    def isBarline(self) -> bool:
        # cache the result for performance
        try:
            return self._isBarlineCached
        except AttributeError:
            self._isBarlineCached: bool = self.text.startswith('=')
            self._atLeastOneCachedTokenTextPropertyExists = True
        return self._isBarlineCached

    '''
        barlineNumber returns the first number found.
        e.g. it returns 23 for both '=!|23' and '=23a:|', etc
    '''
    @property
    def barlineNumber(self) -> int:
        if not self.isBarline:
            return -1

        m = re.search(r'([\d]+)', self.text)
        if m:
            return int(m.group(1))
        return -1

    '''
        barlineName returns the full name of the barline, which is of the form 'Nc',
        where N is an integer (see barlineNumber) and c is a single lower-case
        character, used for alternate endings of a repeated section.
        If there is no suffix character, we return '' (and you should use barlineNumber)
        e.g. it returns '' for '=!|23' and '23a' for '=23a:|', etc
    '''
    @property
    def barlineName(self) -> str:
        if not self.isBarline:
            return ''

        m = re.search(r'([\d]+([a-z]{1}))', self.text)
        if m and m.group(2):
            # we have a barlineNumber with a single lower-case character suffix
            return m.group(1)
        return ''

    '''
    //////////////////////////////
    //
    // HumdrumToken::isCommentGlobal -- Returns true of the token starts with "!!".
    //    Currently confused with reference records.
    '''
    @property
    def isGlobalComment(self) -> bool:
        # cache the result for performance
        try:
            return self._isGlobalCommentCached
        except AttributeError:
            self._isGlobalCommentCached: bool = self.text.startswith('!!')
            self._atLeastOneCachedTokenTextPropertyExists = True
        return self._isGlobalCommentCached

    '''
    //////////////////////////////
    //
    // HumdrumToken::isCommentLocal -- Returns true of the token start with "!",
    //   but not "!!" which is for global comments.
    '''
    @property
    def isLocalComment(self) -> bool:
        # cache the result for performance
        try:
            return self._isLocalCommentCached
        except AttributeError:
            self._isLocalCommentCached: bool = self.isComment and not self.isGlobalComment
            self._atLeastOneCachedTokenTextPropertyExists = True
        return self._isLocalCommentCached

    '''
    //////////////////////////////
    //
    // HumdrumToken::isComment -- Returns true of the token start with "!".
    '''
    @property
    def isComment(self) -> bool:
        # cache the result for performance
        try:
            return self._isCommentCached
        except AttributeError:
            self._isCommentCached: bool = self.text.startswith('!')
            self._atLeastOneCachedTokenTextPropertyExists = True
        return self._isCommentCached

    '''
    //////////////////////////////
    //
    // HumdrumToken::isData -- Returns true if not an interpretation, barline
    //      or local comment.  This will not work on synthetic tokens generated
    //      from an empty line.  So this function should be called only on tokens
    //      in lines which pass the HumdrumLine::hasSpines() test.
    '''
    @property
    def isData(self) -> bool:
        # cache the result for performance
        try:
            return self._isDataCached
        except AttributeError:
            self._isDataCached: bool = (
                not self.isInterpretation
                and not self.isComment
                and not self.isBarline
            )
            self._atLeastOneCachedTokenTextPropertyExists = True
        return self._isDataCached

    '''
    //////////////////////////////
    //
    // HumdrumToken::isInterpretation -- Returns true if an interpretation.
    '''
    @property
    def isInterpretation(self) -> bool:
        # cache the result for performance
        try:
            return self._isInterpretationCached
        except AttributeError:
            self._isInterpretationCached: bool = self.text.startswith('*')
            self._atLeastOneCachedTokenTextPropertyExists = True
        return self._isInterpretationCached

    '''
    //////////////////////////////
    //
    // HumdrumToken::isNonNullData -- Returns true if the token is a data token
    //    that is not a null token.
    '''
    @property
    def isNonNullData(self) -> bool:
        # cache the result for performance
        try:
            return self._isNonNullDataCached
        except AttributeError:
            self._isNonNullDataCached: bool = self.isData and not self.isNull
            self._atLeastOneCachedTokenTextPropertyExists = True
        return self._isNonNullDataCached

    '''
    //////////////////////////////
    //
    // HumdrumToken::isNullData -- Returns true if the token is a null
    //     data token.
    '''
    @property
    def isNullData(self) -> bool:
        # cache the result for performance
        try:
            return self._isNullDataCached
        except AttributeError:
            self._isNullDataCached: bool = self.isData and self.isNull
            self._atLeastOneCachedTokenTextPropertyExists = True
        return self._isNullDataCached

    '''
    //////////////////////////////
    //
    // HumdrumToken::isLabel -- Returns true if a thru label (such as *>A).
    '''
    @property
    def isLabel(self) -> bool:
        # cache the result for performance
        try:
            return self._isLabelCached
        except AttributeError:
            self._isLabelCached: bool = self.text.startswith('*>') and '[' not in self.text
            self._atLeastOneCachedTokenTextPropertyExists = True
        return self._isLabelCached

    '''
    //////////////////////////////
    //
    // HumdrumToken::isChord -- True if is a chord.  Presuming you know what
    //     data type you are accessing.
    //     Default value:
    //          separate = " "   (**kern note separator)
    '''
    @property
    def isChord(self) -> bool:
        # cache the result for performance
        try:
            return self._isChordCached
        except AttributeError:
            self._isChordCached: bool = ' ' in self.text
            self._atLeastOneCachedTokenTextPropertyExists = True
        return self._isChordCached

    '''
    //////////////////////////////
    //
    // HumdrumToken::isExclusiveInterpretation -- Returns true if first two
    //     characters are "**".
    '''
    @property
    def isExclusiveInterpretation(self) -> bool:
        # cache the result for performance
        try:
            return self._isExclusiveInterpretationCached
        except AttributeError:
            self._isExclusiveInterpretationCached: bool = self.text.startswith('**')
            self._atLeastOneCachedTokenTextPropertyExists = True
        return self._isExclusiveInterpretationCached

    '''
    //////////////////////////////
    //
    // HumdrumToken::isSplitInterpretation -- True if the token is "*^".
    '''
    @property
    def isSplitInterpretation(self) -> bool:
        # cache the result for performance
        try:
            return self._isSplitInterpretationCached
        except AttributeError:
            self._isSplitInterpretationCached: bool = self.text == SPLIT_TOKEN
            self._atLeastOneCachedTokenTextPropertyExists = True
        return self._isSplitInterpretationCached

    '''
    //////////////////////////////
    //
    // HumdrumToken::isMergeInterpretation -- True if the token is "*v".
    '''
    @property
    def isMergeInterpretation(self) -> bool:
        # cache the result for performance
        try:
            return self._isMergeInterpretationCached
        except AttributeError:
            self._isMergeInterpretationCached: bool = self.text == MERGE_TOKEN
            self._atLeastOneCachedTokenTextPropertyExists = True
        return self._isMergeInterpretationCached

    '''
    //////////////////////////////
    //
    // HumdrumToken::isExchangeInterpretation -- True if the token is "*x".
    '''
    @property
    def isExchangeInterpretation(self) -> bool:
        # cache the result for performance
        try:
            return self._isExchangeInterpretationCached
        except AttributeError:
            self._isExchangeInterpretationCached: bool = self.text == EXCHANGE_TOKEN
            self._atLeastOneCachedTokenTextPropertyExists = True
        return self._isExchangeInterpretationCached

    '''
    //////////////////////////////
    //
    // HumdrumToken::isTerminateInterpretation -- True if the token is "*-".
    '''
    @property
    def isTerminateInterpretation(self) -> bool:
        # cache the result for performance
        try:
            return self._isTerminateInterpretationCached
        except AttributeError:
            self._isTerminateInterpretationCached: bool = self.text == TERMINATE_TOKEN
            self._atLeastOneCachedTokenTextPropertyExists = True
        return self._isTerminateInterpretationCached

    '''
    //////////////////////////////
    //
    // HumdrumToken::isAddInterpretation -- True if the token is "*+".
    '''
    @property
    def isAddInterpretation(self) -> bool:
        # cache the result for performance
        try:
            return self._isAddInterpretationCached
        except AttributeError:
            self._isAddInterpretationCached: bool = self.text == ADD_TOKEN
            self._atLeastOneCachedTokenTextPropertyExists = True
        return self._isAddInterpretationCached

    '''
    //////////////////////////////
    //
    // HumdrumToken::isNull -- Returns true if the token is a null token,
    //   either for data, comments, or interpretations.  Does not consider
    //   null global comments since they are not part of the spine structure.
    '''
    @property
    def isNull(self) -> bool:
        # cache the result for performance
        try:
            return self._isNullCached
        except AttributeError:
            self._isNullCached: bool = (
                self.text in (NULL_DATA, NULL_INTERPRETATION, NULL_COMMENT_LOCAL)
            )
            self._atLeastOneCachedTokenTextPropertyExists = True
        return self._isNullCached

    '''
    //////////////////////////////
    //
    // HumdrumToken::noteInLowerSubtrack -- Return true if the note
    //     is attacked or sustained with another note in a lower layer.
    //     This is for using in hum2mei conversion to avoid a bug in
    //     verovio related to lyrics in layers where the notes are a
    //     second apart.
        iohumdrum.cpp in Verovio is the only client --gregc
    '''
    @property
    def noteInLowerSubtrack(self) -> bool:
        if self.subTrack <= 1:
            return False
        if self.ownerLine is None:
            return False

        track: int | None = self.track
        if track is None:
            return False

        # loops from field-1 to 0 (end index -1 is exclusive), incrementing by -1
        for i in range(self.fieldIndex - 1, -1, -1):
            xtoken = self.ownerLine.token[i]
            if xtoken.track != track:
                return False
            if xtoken.isNull:
                continue
            if 'r' in xtoken.text:
                continue
            return True

        return False


    '''
    //////////////////////////////
    //
    // HumdrumToken::getTrackString -- Gets "track.subtrack" as a string.  The
    //     track and subtrack are integers.  The getTrackString function will
    //     return a string with the track and subtrack separated by an dot.  The
    //     Dot is not a decimal point, but if the subtrack count does not exceed
    //     9, then the returned string can be treated as a floating-point number
    //     where the subtrack is the fractional part.
    // @SEEALSO: getTrack, getSubtrack
    '''
    @property
    def trackString(self) -> str:
        return self._address.trackString

    '''
    //////////////////////////////
    //
    // HumdrumToken::getSubtokenCount -- Returns the number of sub-tokens in
    //     a token.  The input parameter is the sub-token separator.  If the
    //     separator comes at the start or end of the token, then there will
    //     be empty sub-token(s) included in the count.
    // default value: separator = " "
    // @SEEALSO: getSubtoken

        Separator is always ' ' here, so subtokenCount can be a property. --gregc
    '''
    @property
    def subtokenCount(self) -> int:
        return len(self.subtokens)

    '''
    /////////////////////////////
    //
    // HumdrumToken::getSubtoken -- Extract the specified sub-token from the token.
    //    Tokens usually are separated by spaces in Humdrum files, but this will
    //    depened on the data type (so therefore, the tokens are not presplit into
    //    sub-tokens when reading in the file).
    // default value: separator = " "
    // @SEEALSO: getSubtokenCount, getTrackString

        Instead of getSubtoken(index), we have (below) a subtokens property which
        generates a list of subtoken strings for the client to index into.  We
        cache it for performance. --gregc
    '''

    '''
    //////////////////////////////
    //
    // HumdrumToken::getSubtokens -- Return the list of subtokens as an array
    //     of strings.
    //     default value: separator = " "
    '''
    @property
    def subtokens(self) -> list[str]:
        if not self._subtokensGenerated:
            self._subtokens = self.text.split(' ')
            self._subtokensGenerated = True

        return self._subtokens
    '''
    //////////////////////////////
    //
    // HumdrumToken::replaceSubtoken --
    //     default value: separator = " "
    '''
    def replaceSubtoken(self, index: int, newSubtoken: str) -> None:
        if index < 0 or index >= self.subtokenCount:
            return
        self.subtokens[index] = newSubtoken
        self.text = ' '.join(self.subtokens)
        # Set _subtokensGenerated to True, because setting self.text clears it,
        # but we know the subtokens are there.
        self._subtokensGenerated = True

    '''
    //////////////////////////////
    //
    // HumdrumToken::addLinkedParameterSet --
    '''
    def addLinkedParameterSet(self, token: 'HumdrumToken') -> int:
        if ':ignore' in token.text:
            '''
            // Ignore layout command (store layout command but
            // do not use it).  This is particularly for adding
            // layout parameters for notation, but the parameters
            // currently cause problems in verovio (so they should
            // be unignored at a future date when the layout
            // parameter is handled better).  Note that any
            // parameter starting with "ignore" such as "ignored"
            // will also be suppressed by this if statement.
            '''
            return -1

        for i, linkedToken in enumerate(self._linkedParameterTokens):
            if linkedToken == token:
                return i

        if not self._linkedParameterTokens:
            self._linkedParameterTokens.append(token)
            return 0

        # Insert token into the list, keeping it sorted by line index
        if token.lineIndex >= self._linkedParameterTokens[-1].lineIndex:
            self._linkedParameterTokens.append(token)
            return self.linkedParameterSetCount - 1

        for i, linkedToken in enumerate(self._linkedParameterTokens):
            if token.lineIndex < linkedToken.lineIndex:
                self._linkedParameterTokens.insert(i, token)
                return i  # bugfix: C++ version returns len-1

        # I don't think you can get here, but if you do, you failed.
        # Treat it as 'ignored' (store layout command but do not use it).
        return -1

    '''
    //////////////////////////////
    //
    // HumdrumToken::linkedParameterIsGlobal --
    '''
    def linkedParameterIsGlobal(self, index: int) -> bool:
        if index not in range(0, self.linkedParameterSetCount):
            return False
        return self._linkedParameterTokens[index].isGlobalComment

    '''
    //////////////////////////////
    //
    // HumdrumToken::getLinkedParameterSetCount --
    '''
    @property
    def linkedParameterSetCount(self) -> int:
        return len(self._linkedParameterTokens)

    '''
    //////////////////////////////
    //
    // HumdrumToken::getParameterSet --
    '''
    @property
    def parameterSet(self) -> HumParamSet | None:
        return self._parameterSet

    '''
    //////////////////////////////
    //
    // HumdrumToken::getLinkedParameterSet --
    '''
    def getLinkedParameterSet(self, index: int) -> HumParamSet | None:
        if index not in range(0, self.linkedParameterSetCount):
            return None
        return self._linkedParameterTokens[index].parameterSet

    '''
    //////////////////////////////
    //
    // HumdrumToken::storeParameterSet -- Store the contents of the token
    //    in the parameter storage.  Used for layout parameters.
    '''
    def storeParameterSet(self) -> None:
        if (self.isLocalComment or self.isGlobalComment) and ':' in self.text:
            self._parameterSet = HumParamSet(self.text)

    '''
    //////////////////////////////
    //
    // HumdrumToken::makeForwardLink -- Link a following spine token to this one.
    //    Used by the HumdrumFileBase::analyzeLinks function.
    '''
    def makeForwardLink(self, nextToken: 'HumdrumToken') -> None:
        self.nextTokens.append(nextToken)
        self.updateNextToken0()
        nextToken.previousTokens.append(self)
        nextToken.updatePreviousToken0()

    '''
    //////////////////////////////
    //
    // HumdrumToken::makeBackwardLink -- Link a previous spine token to this one.
    //    Used by the HumdrumFileBase::analyzeLinks function.
    '''
    def makeBackwardLink(self, previousToken: 'HumdrumToken') -> None:
        self.previousTokens.append(previousToken)
        self.updatePreviousToken0()
        previousToken.nextTokens.append(self)
        previousToken.updateNextToken0()

    '''
    //////////////////////////////
    //
    // HumdrumToken::getOwner -- Returns a pointer to the HumdrumLine that
    //    owns this token.
    '''
    @property
    def ownerLine(self):  # -> HumdrumLine | None:
        return self._address.ownerLine

    @ownerLine.setter
    def ownerLine(self, newOwnerLine) -> None:  # newOwnerLine: HumdrumLine | None
        from converter21.humdrum import HumdrumLine
        if newOwnerLine is not None and not isinstance(newOwnerLine, HumdrumLine):
            raise HumdrumInternalError('invalid newOwnerLine')
        self._address.ownerLine = newOwnerLine
        if self._atLeastOneCachedDataTypePropertyExists:
            # some cached properties depend on dataType,
            # which depends on ownerLine and track
            self._clearCachedDataTypeProperties()

    '''
    //////////////////////////////
    //
    // HumdrumToken::getState -- Returns the rhythm state variable.
    '''
    @property
    def rhythmAnalysisState(self) -> int:
        return self._rhythmAnalysisState

    '''
    //////////////////////////////
    //
    // HumdrumToken::incrementState -- update the rhythm analysis state variable.
    //    This will prevent redundant recursive analysis in analyzeRhythm of
    //    the HumdrumFileStructure class.
    '''
    def incrementRhythmAnalysisState(self) -> None:
        self._rhythmAnalysisState += 1

    '''
    //////////////////////////////
    //
    // HumdrumToken::getStrandIndex -- Returns the 1-D strand index
    //    that the token belongs to in the owning HumdrumFile.
    //    Returns -1 if there is no strand assignment.
    '''
    @property
    def strandIndex(self) -> int:
        return self._strandIndex

    @strandIndex.setter
    def strandIndex(self, newStrandIndex: int) -> None:
        self._strandIndex = newStrandIndex

    '''
    //////////////////////////////
    //
    // HumdrumToken::getSlurStartElisionLevel -- Returns the count of
    //   elision marks ('&') preceding a slur start character '('.
    //   Returns -1 if there is no slur start character.
    //   Default value: index = 0
    '''
    def startElisionLevel(self, slurOrPhrase: str, index: int = 0) -> int:
        if self.isKern or self.isMens:
            if slurOrPhrase == HumdrumToken.SLUR:
                return Convert.getKernSlurStartElisionLevel(self.text, index)
            if slurOrPhrase == HumdrumToken.PHRASE:
                return Convert.getKernPhraseStartElisionLevel(self.text, index)

        return -1

    '''
    //////////////////////////////
    //
    // HumdrumToken::getSlurEndElisionLevel -- Returns the count of
    //   elision marks ('&') preceding a slur end character ')'.
    //   Returns -1 if there is no slur end character.
    //   Default value: index = 0
    '''
    def endElisionLevel(self, slurOrPhrase: str, index: int = 0) -> int:
        if self.isKern or self.isMens:
            if slurOrPhrase == HumdrumToken.SLUR:
                return Convert.getKernSlurEndElisionLevel(self.text, index)
            if slurOrPhrase == HumdrumToken.PHRASE:
                return Convert.getKernPhraseEndElisionLevel(self.text, index)

        return -1

    '''
    //////////////////////////////
    //
    // HumdrumToken::getNextTokenCount -- Returns the number of tokens in the
    //   spine/sub spine which follow this token.  Typically this will be 1,
    //   but will be zero for a terminator interpretation (*-), and will be
    //   2 for a split interpretation (*^).
    '''
    @property
    def nextTokenCount(self) -> int:
        return len(self.nextTokens)

    '''
    //////////////////////////////
    //
    // HumdrumToken::getPreviousTokenCount -- Returns the number of tokens
    //   in the spine/sub-spine which precede this token.  Typically this will
    //   be 1, but will be zero for an exclusive interpretation (starting with
    //   "**"), and will be greater than one for a token which follows a
    //   spine merger (using *v interpretations).
    '''
    @property
    def previousTokenCount(self) -> int:
        return len(self.previousTokens)

    '''
    //////////////////////////////
    //
    // HumdrumToken::getSlurStartToken -- Return a pointer to the token
    //     which starts the given slur.  Returns NULL if no start.  Assumes that
    //     HumdrumFileContent::analyzeKernSlurs() has already been run.
    //                <parameter key="slurEnd" value="HT_140366146702320" idref=""/>
        Storing a pointer in a string is not something Python (or I) can deal with.
        We simply set/get a reference to the token itself in the HumHash instead. --gregc
    '''
    def getSlurStartToken(self, number: int) -> 'HumdrumToken':
        return self.getValueToken('auto', makeTag('slurStartId', number))

    '''
    //////////////////////////////
    //
    // HumdrumToken::getSlurStartNumber -- Given a slur ending number,
    //    return the slur start number that it pairs with.
    '''
    def getSlurStartNumber(self, number: int) -> int:
        return self.getValueInt('auto', makeTag('slurStartNumber', number))

    '''
    //////////////////////////////
    //
    // HumdrumToken::getSlurEndToken -- Return a pointer to the token
    //     which ends the given slur.  Returns NULL if no end.  Assumes that
    //     HumdrumFileContent::analyzeKernSlurs() has already been run.
    //                <parameter key="slurStart" value="HT_140366146702320" idref=""/>
        Storing a pointer in a string is not something Python (or I) can deal with.
        We simply set/get a reference to the token itself in the HumHash instead. --gregc
    '''
    def getSlurEndToken(self, number: int) -> 'HumdrumToken':
        return self.getValueToken('auto', makeTag('slurEnd', number))

    '''
    //////////////////////////////
    //
    // HumdrumToken::getPhraseStartToken -- Return a pointer to the token
    //     which starts the given phrase.  Returns NULL if no start.  Assumes that
    //     HumdrumFileContent::analyzeKernPhrasings() has already been run.
    //                <parameter key="phraseEnd" value="HT_140366146702320" idref=""/>
        Storing a pointer in a string is not something Python (or I) can deal with.
        We simply set/get a reference to the token itself in the HumHash instead. --gregc
    '''
    def getPhraseStartToken(self, number: int) -> 'HumdrumToken':
        return self.getValueToken('auto', makeTag('phraseStart', number))

    '''
    //////////////////////////////
    //
    // HumdrumToken::getPhraseEndToken -- Return a pointer to the token
    //     which ends the given phrase.  Returns NULL if no end.  Assumes that
    //     HumdrumFileContent::analyzeKernPhrasings() has already been run.
    //                <parameter key="phraseStart" value="HT_140366146702320" idref=""/>
        Storing a pointer in a string is not something Python (or I) can deal with.
        We simply set/get a reference to the token itself in the HumHash instead. --gregc
    '''
    def getPhraseEndToken(self, number: int) -> 'HumdrumToken':
        return self.getValueToken('auto', makeTag('phraseEnd', number))

    '''
    //////////////////////////////
    //
    // HumdrumToken::resolveNull --
    '''
    @property
    def nullResolution(self) -> 'HumdrumToken':
        if self._nullResolution is not None:
            return self._nullResolution

        if self.ownerLine is not None and self.ownerLine.ownerFile is not None:
            self.ownerLine.ownerFile.resolveNullTokens()

        if self._nullResolution is not None:
            return self._nullResolution

        return self

    @nullResolution.setter
    def nullResolution(self, newNullResolution: 'HumdrumToken') -> None:
        self._nullResolution = newNullResolution

    '''
    //////////////////////////////
    //
    // HumdrumToken::copyStucture --
    '''
    def copyStructure(self, fromToken: 'HumdrumToken') -> None:
        # pylint: disable=protected-access
        from converter21.humdrum import HumdrumLine
        self._strandIndex = fromToken._strandIndex

        # pick up original ownerLine before you overwrite _address
        originalOwnerLine: HumdrumLine | None = self._address.ownerLine
        self._address = copy.copy(fromToken._address)

        # preserve the original ownerLine
        self._address.ownerLine = originalOwnerLine
        # pylint: enable=protected-access

    '''
    //////////////////////////////
    //
    // HumdrumToken::getStrophe -- return the strophe that the token belongs to,
    //    or NULL if it is not in a strophe.
    '''
    @property
    def strophe(self) -> t.Optional['HumdrumToken']:
        return self._strophe

    @strophe.setter
    def strophe(self, strophe: t.Optional['HumdrumToken']) -> None:
        if strophe is None:
            self._strophe = None
            return

        if not strophe.text.startswith('*S/'):
            # invalid strophe marker.
            self._strophe = None
            return

        self._strophe = strophe

    '''
    //////////////////////////////
    //
    // HumdrumToken::hasStrophe -- return true if the token is in a strophe; otherwise,
    //    return false.
    '''
    @property
    def hasStrophe(self) -> bool:
        return self.strophe is not None


    '''
    //////////////////////////////
    //
    // HumdrumToken::isFirstStrophe -- Returns true if the token is in the first
    //    strophe variant.  Returns true if not in a strophe.
    '''
    @property
    def isFirstStrophe(self) -> bool:
        if self.strophe is None:
            return True

        toleft = self.strophe.previousFieldToken
        if toleft is None:
            return True

        return self.strophe.track != toleft.track

    '''
    //////////////////////////////
    //
    // HumdrumToken::isStrophe -- Return true if the token has the given strophe
    //   label.
    '''
    def isStrophe(self, label: str) -> bool:
        if self.strophe is None:
            return False

        if label is None or label == '':
            return self.strophe.text == '*S/'

        if label.startswith('*'):
            return self.strophe.text == label

        return self.strophe.text[3:] == label

    '''
    //////////////////////////////
    //
    // HumdrumToken::getStropheLabel -- Return the strophe label after *S/ in the
    //    strophe token.  Returns the empty string when not in a strophe.
    '''
    @property
    def stropheLabel(self) -> str:
        if self.strophe is None:
            return ''

        if len(self.strophe.text) <= 3:
            return ''

        if self.strophe.text == '*S/':
            return ''

        return self.strophe.text[3:]

    '''
        LayoutParameter stuff
    '''

    '''
    //////////////////////////////
    //
    // HumdrumToken::getLayoutParameter -- Returns requested layout parameter
    //     if it is attached to a token directly or indirectly through a linked
    //     parameter.  Returns empty string if no explicit visual durtation (so
    //     the visual duration is same as the logical duration).  If subtokenindex
    //     is less than -1 (the default value for the paramter), then ignore the
    //     @n parameter control for indexing the layout parameter to chord notes.
    //     The subtokenindex (0 indexed) is converted to note number (1 indexed)
    //     for checking @n.  @n is currently only allowed to be a single integer
    //     (eventually allow ranges and multiple values).
    '''
    def layoutParameter(self, category: str, keyName: str, subtokenIndex: int = -1) -> str:
        # First check for any local layout parameter:
        testOutput: str | None = self.getValueString('LO', category, keyName)
        if testOutput:
            if subtokenIndex >= 0:
                n: int = self.getValueInt('LO', category, 'n')
                if n == subtokenIndex + 1:
                    return testOutput
            else:
                return testOutput

        output: str = ''
        lcount: int = self.linkedParameterSetCount
        if lcount == 0:
            return output

        nparam: str = ''
        for p in range(0, lcount):
            hps: HumParamSet | None = self.getLinkedParameterSet(p)
            if hps is None:
                continue
            if hps.namespace1 != 'LO':
                continue
            if hps.namespace2 != category:
                continue

            output = ''
            for q in range(0, hps.count):
                key: str = hps.getParameterName(q)
                if key == keyName:
                    output = hps.getParameterValue(q)
                    if subtokenIndex < 0:
                        return output
                if key == 'n':
                    nparam = hps.getParameterValue(q)

            if not nparam:
                # no subtoken selection for this parameter,
                # so return if not empty
                if output:
                    return output
            elif subtokenIndex < 0:
                # no subtoken selection so return output if not empty
                if output:
                    return output
            else:
                # There is a subtoken selection number, so
                # return output if n matches it (minus one)

                # currently @n requires a single value
                # (should allow a range or multiple values
                # later).  Also not checking validity of
                # string first (needs to start with a digit);
                np: int = int(nparam)
                if np == subtokenIndex + 1:
                    return output

                # not the output that is required,
                # so suppress for end of loop
                output = ''

        return output

    def getBooleanLayoutParameter(self, category: str, key: str) -> bool:
        lcount: int = self.linkedParameterSetCount
        for i in range(0, lcount):
            hps: HumParamSet | None = self.getLinkedParameterSet(i)
            if not hps:
                continue
            if hps.namespace1 != 'LO':
                continue
            if hps.namespace2 != category:
                continue
            pkey: str = ''
            for j in range(0, hps.count):
                pkey = hps.getParameterName(j)
                if pkey == key:
                    return True
        return False

    def getStringLayoutParameter(self, category: str, key: str) -> str:
        lcount: int = self.linkedParameterSetCount
        for i in range(0, lcount):
            hps: HumParamSet | None = self.getLinkedParameterSet(i)
            if not hps:
                continue
            if hps.namespace1 != 'LO':
                continue
            if hps.namespace2 != category:
                continue
            pkey: str = ''
            for j in range(0, hps.count):
                pkey = hps.getParameterName(j)
                if pkey == key:
                    return hps.getParameterValue(j)
        return ''

    '''
    //////////////////////////////
    //
    // HumdrumToken::getVisualDuration -- Returns LO:N:vis parameter if it is attached
    //    to a token directly or indirectly through a linked parameter.  Returns empty string
    //    if no explicit visual duration (so the visual duration is same as the logical duration).
    '''
    def getVisualDuration(self, subtokenIdx: int = -1) -> str:
        visDurStr = self.layoutParameter('N', 'vis', subtokenIdx)
        # if visDurStr:
        #     print('visualDuration(recip) = {}, origDur(quarterLength) = {}'.format(
        #               visDurStr, self.duration), file=sys.stderr)
        return visDurStr

    '''
        XML and other print stuff
    '''
